diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b6e732d..1541059 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.8", + "version": "0.0.9", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 13d84c1..6cdfeb2 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.8", + "version": "0.0.9", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 9dac0c3..a0a709d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,12 +34,12 @@ **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 -12. **圖示必須是重新繪製的人物形象圖(有臉)**:上網找出該人格「最新一次登場」的官方視覺 → - `icon headshot` 裁出**大頭照當底稿** → **用 Read 親眼看過** → 讀出髮型/瀏海/眼型/表情/ - 髮飾等特徵 → `icon generate --palette ... --features ...` **重新繪製**。 - **絕對不要把找到的圖片直接當圖示**(底稿只留在 `.sync/`,不同步、不發佈; - 產出的 SVG 不得有 ``/base64/外連)。 - **沒看過圖就不准填顏色或特徵、也不准亂挑臉**(多角色先 `icon faces` 再挑,挑完打開確認)。 +12. **形象圖優先用「高解析度官方圖去背」**:`icon search` → `icon measure` → `icon cutout` + → `icon generate --from-cutout`。找圖時**優先官方設定稿**(Full Body/Character Design/ + Avatar):解析度高,而且多半是透明底或白底,去背幾乎免費。挑的那張要**對得上該人格 + 「最新一次登場」的形態**(同一個角色有很多套造型)。 + 每一步都要**用 Read 打開確認**:去背有沒有殘留、構圖對不對。 + 找不到可用官方圖才退回 `--features` 的向量重繪。 13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 diff --git a/README.md b/README.md index 9edd9ae..ef7a318 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | -| **12. 圖示是重新繪製的人物形象圖(有臉)** | 上網找出該人格**最新一次登場**的官方視覺 → 裁出**大頭照當底稿** → **親眼看過** → 讀出髮型/瀏海/眼型/表情/髮飾等特徵 → **由本工具依人格資料重新繪製**。**絕不把找到的圖片當圖示**:SVG 裡沒有 ``、沒有 base64、沒有外連,底稿只留在 `.sync/` 不同步。來源網址與重繪依據存進 `config.json` 備查 | +| **12. 形象圖來自高解析度官方圖,去背後合成** | `icon search` 從 Fandom 撈官方圖並依「解析度+是否官方設定稿」排序(設定稿多為透明/白底、773×1056 起跳)→ `icon measure` 確認臉夠大、背景好去 → `icon cutout` 去背成透明 PNG(原生 alpha /單色底/GrabCut 三條路徑)→ `icon generate --from-cutout` 裁頭肩、合成到角色配色的漸層底。找不到官方圖才退回依人格資料重繪的向量形象 | | **13. Wiki 必須保存並同步形象圖** | `icon.svg`、`icon.png` 與 **`icon/` 資料夾(向量原稿 + 512/1024 高解析度)** 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示與來源。Wiki 頁面一律用 **Markdown 圖片語法**(Gitea 只改寫這種語法為 `/wiki/raw/...`;HTML `` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` | --- @@ -150,29 +150,24 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 建立人格**並補齊 IDENTITY/SOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**, 依環境有無工具分三種樣式: -流程是 **找圖 → 裁大頭照當底稿 → 親眼看過 → 讀出特徵 → 重新繪製**: +流程:**找官方圖 → 量測 → 去背 → 裁頭肩 → 合成** | 步驟 | 指令 | 產出 | | --- | --- | --- | -| 1. 找臉 | `icon faces --photo <圖>` | 圖裡所有臉的座標(多角色務必先看) | -| 2. 裁底稿 | `icon headshot --photo <圖> --pick N` | `.sync/headshot.png`(**不是圖示、不同步**) | -| 3. 看 | Read 打開底稿 + 讀 IDENTITY/SOUL | 髮型、瀏海、眼型、表情、髮飾、領口 | -| 4. 重繪 | `icon generate --palette ... --features ...` | `icon.svg` + `icon.png` | +| 1. 找圖 | `icon search --wiki --page <角色>` | 候選清單,📐 = 官方設定稿(優先) | +| 2. 量測 | `icon measure --photo <網址>` | 解析度、臉多大、背景透明/單色/有場景 | +| 3. 去背 | `icon cutout --photo <網址>` | `icon/portrait-cutout.png`(透明 PNG) | +| 4. 合成 | `icon generate --from-cutout --palette ...` | `icon.svg` + `icon.png` + `icon/` 多解析度 | + +去背三條路徑自動選:**原生 alpha**(官方設定稿常見,完美)→ **單色底移除**(很好)→ +**GrabCut**(有場景時,邊緣普通;黑髮角色容易被誤切,這時該換設定稿)。 | 樣式 | 條件 | 長什麼樣 | | --- | --- | --- | -| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像:漸層虹膜+雙高光+睫毛、髮絲與反光、腮紅與下巴陰影 | +| **`cutout`** | 有去背圖 → 最佳 | 官方原圖裁頭肩,疊在角色配色的漸層底上 | +| **`portrait`** | 沒有可用官方圖 | 依特徵重繪的人物頭像,五官俱全 | | **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 | -輸出(每次 `icon generate` 都會全部更新): - -``` -icon.svg / icon.png 根目錄:Wiki 頁面與存取庫頭像用(512) -icon/portrait.svg 向量原稿,可無限放大 -icon/portrait-512.png 高解析度 -icon/portrait-1024.png 高解析度 -``` - 柵格器對每個圖形先算 bounding box 再逐點測試,1024×1024(3× 超取樣)約 1.8 秒。 **找到的圖片只能當底稿**:產出的 SVG 沒有 ``、沒有 base64、沒有外連,每個像素都是畫出來的。 @@ -389,7 +384,7 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 168 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、形象圖重繪、高解析輸出與 Wiki 同步、hooks +node scripts/selftest.mjs # 175 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/plugin.json b/plugin.json index c176f0d..08c1a6f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.8", + "version": "0.0.9", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 2214218..cb1b2da 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -657,7 +657,8 @@ export async function verifyArea(slug, area, { code = null, owner = null } = {}) } const files = stageArea(slug, area, dir); const dirty = git(["status", "--porcelain"], dir) - .stdout.split("\n").map((l) => l.replace(/^.{2}\s+/, "").trim()).filter(Boolean); + // git() 會把輸出 trim 掉,所以 porcelain 開頭那個空白可能已經不見了(" M x" → "M x") + .stdout.split("\n").map((l) => l.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()).filter(Boolean); git(["checkout", "--", "."], dir); git(["clean", "-qfd"], dir); return { diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index 2c69302..35e5079 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -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 [ + ``, + ` ${spec.code}${spec.name ? ` ${spec.name}` : ""}`, + ` `, + "", + "", + ].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 依人格資料重新繪製。 diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 244c242..675a97b 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -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 ` 與 `--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 - faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看) + icon search|measure|faces|headshot|cutout|generate|show --session + search --wiki --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 ] - **依人格資料重新繪製**人物頭像;不會把來源圖放進圖示。 - 沒帶 --palette → 徽章樣式(配色由編號/名字/emoji 雜湊)。 + [--from-cutout] [--zoom 2.15] [--pick <索引>] + 帶 --from-cutout → 用去背好的官方原圖合成(頭肩構圖,最忠於原作)。 + 否則依人格資料重新繪製;沒帶 --palette → 徽章樣式。 show 看目前的樣式、配色、特徵與來源 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 diff --git a/scripts/portrait.py b/scripts/portrait.py index 1abb910..7379a93 100644 --- a/scripts/portrait.py +++ b/scripts/portrait.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 -"""從參考照片裁出人物臉部,輸出成人格圖示用的正方形 PNG。 +"""人格形象圖的影像處理:量測、找臉、裁大頭照、去背、合成成圖示。 -這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它照樣能產生 -向量人物形象。裝了 Pillow(+可選的 OpenCV 動漫臉偵測)之後,圖示就能改用真實照片裁臉。 +這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它也能畫出 +向量人物形象;裝了 Pillow(+可選的 OpenCV 臉部偵測)之後,就能改用官方圖去背當形象圖。 -用法: - python3 portrait.py --input <圖片> --list # 列出偵測到的所有臉 - python3 portrait.py --input <圖片> --output [--size 512] - [--cascade ] [--pick largest|leftmost|rightmost|] - [--face x,y,w,h] # 直接指定裁切框 +模式: + --mode measure 量測:尺寸、臉的位置、背景是不是單色(決定去背好不好做) + --mode faces 列出偵測到的所有臉 + --mode headshot 裁出大頭照(給人看的底稿) + --mode cutout 去背,輸出透明 PNG + --mode compose 把去背圖合成到圓角漸層底上,產出最終圖示 -多角色的圖片一定要挑臉:`--list` 看有哪些,再用 `--pick` 或 `--face` 指定。 -輸出(stdout):一行 JSON。失敗時 ok=false,並附上 hint。 +輸出(stdout):一行 JSON。失敗時 ok=false 並附上 hint。 """ import argparse @@ -29,13 +29,19 @@ def fail(reason, hint=None): emit({"ok": False, "reason": reason, "hint": hint}) -def detect_faces(path, cascade_path): - """回傳 (faces, method);faces 是 [(x, y, w, h), ...],偵測不到就回 ([], None)。""" +def load_cv2(): try: import cv2 + return cv2 except ImportError: - return [], None + return None + +def detect_faces(path, cascade_path): + """回傳 (faces, method)。faces = [(x,y,w,h), ...]。""" + cv2 = load_cv2() + if cv2 is None: + return [], None candidates = [] if cascade_path and os.path.exists(cascade_path): candidates.append((cascade_path, "anime-cascade")) @@ -44,7 +50,6 @@ def detect_faces(path, cascade_path): frontal = os.path.join(builtin, "haarcascade_frontalface_default.xml") if os.path.exists(frontal): candidates.append((frontal, "frontal-cascade")) - try: image = cv2.imread(path) if image is None: @@ -52,9 +57,6 @@ def detect_faces(path, cascade_path): gray = cv2.equalizeHist(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)) except Exception: return [], None - - # 由嚴到寬試幾組參數:先求準,找不到才放寬 - ladder = [(1.05, 5, 40), (1.05, 3, 32), (1.02, 2, 24)] for xml, method in candidates: try: clf = cv2.CascadeClassifier(xml) @@ -62,7 +64,7 @@ def detect_faces(path, cascade_path): continue except Exception: continue - for sf, mn, ms in ladder: + for sf, mn, ms in [(1.05, 5, 40), (1.05, 3, 32), (1.02, 2, 24)]: try: faces = clf.detectMultiScale(gray, scaleFactor=sf, minNeighbors=mn, minSize=(ms, ms)) except Exception: @@ -89,87 +91,278 @@ def choose(faces, pick): return None +def background_report(img): + """看四個角與外框一圈:底色一不一致、是不是淺色。單色底=去背可以做得很乾淨。""" + w, h = img.size + px = img.convert("RGB").load() + samples = [] + step = max(1, min(w, h) // 60) + for x in range(0, w, step): + samples.append(px[x, 0]) + samples.append(px[x, h - 1]) + for y in range(0, h, step): + samples.append(px[0, y]) + samples.append(px[w - 1, y]) + avg = tuple(sum(c[i] for c in samples) / len(samples) for i in range(3)) + var = sum(max(abs(c[i] - avg[i]) for i in range(3)) for c in samples) / len(samples) + return { + "color": [round(v) for v in avg], + "spread": round(var, 1), # 越小越單色 + "plain": bool(var < 18), # 單色底 + "light": bool(sum(avg) / 3 > 200), + } + + +def alpha_from_plain_bg(img, bg_color, tol=34, feather=1.2): + """單色底去背:離底色越近越透明,再對邊緣做一點羽化。""" + from PIL import Image, ImageFilter + import math + rgb = img.convert("RGB") + w, h = rgb.size + px = rgb.load() + mask = Image.new("L", (w, h), 255) + mp = mask.load() + br, bg_, bb = bg_color + hard = tol * tol + soft = (tol * 2.1) ** 2 + for y in range(h): + for x in range(w): + r, g, b = px[x, y] + d = (r - br) ** 2 + (g - bg_) ** 2 + (b - bb) ** 2 + if d <= hard: + mp[x, y] = 0 + elif d < soft: + mp[x, y] = int(255 * (math.sqrt(d) - tol) / (tol * 1.1)) + # 只留最大的一塊,避免把角色身上和底色相近的區塊也挖掉 + cv2 = load_cv2() + if cv2 is not None: + import numpy as np + m = np.array(mask) + binary = (m > 96).astype("uint8") + binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) + n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8) + if n > 1: + largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) + keep = (labels == largest) + # 洞(例如手臂圍出的空隙)補回來 + filled = cv2.morphologyEx(keep.astype("uint8"), cv2.MORPH_CLOSE, np.ones((15, 15), np.uint8)) + m = np.where(filled > 0, m, 0) + mask = Image.fromarray(m) + return mask.filter(ImageFilter.GaussianBlur(feather)) + + +def alpha_from_grabcut(path, face, iters=8): + """有背景的圖:用臉的位置當前景種子跑 GrabCut。""" + cv2 = load_cv2() + if cv2 is None: + return None + import numpy as np + from PIL import Image + img = cv2.imread(path) + if img is None: + return None + h, w = img.shape[:2] + mask = np.full((h, w), cv2.GC_PR_BGD, np.uint8) + mask[int(h * 0.03):int(h * 0.99), int(w * 0.05):int(w * 0.95)] = cv2.GC_PR_FGD + if face is not None: + fx, fy, fw, fh = face + cx, cy = fx + fw // 2, fy + fh // 2 + cv2.ellipse(mask, (cx, cy), (int(fw * 0.42), int(fh * 0.48)), 0, 0, 360, cv2.GC_FGD, -1) + cv2.ellipse(mask, (cx, int(cy - fh * 0.28)), (int(fw * 0.80), int(fh * 0.70)), 0, 0, 360, + cv2.GC_FGD, -1) + cv2.rectangle(mask, (int(cx - fw * 0.85), int(cy + fh * 0.8)), (int(cx + fw * 0.85), h - 1), + cv2.GC_FGD, -1) + b = max(2, int(min(w, h) * 0.015)) + mask[:b, :] = cv2.GC_BGD + mask[-b:, :] = cv2.GC_BGD + mask[:, :b] = cv2.GC_BGD + mask[:, -b:] = cv2.GC_BGD + bgd, fgd = np.zeros((1, 65), np.float64), np.zeros((1, 65), np.float64) + try: + cv2.grabCut(img, mask, None, bgd, fgd, iters, cv2.GC_INIT_WITH_MASK) + except Exception: + return None + m = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD), 255, 0).astype("uint8") + n, labels, stats, _ = cv2.connectedComponentsWithStats((m > 0).astype("uint8"), 8) + if n > 1: + largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) + m = np.where(labels == largest, 255, 0).astype("uint8") + m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)) + m = cv2.GaussianBlur(m, (5, 5), 0) + return Image.fromarray(m) + + +def head_box(img_size, face, zoom): + """由臉的框推出「頭肩構圖」的正方形裁切框。""" + W, H = img_size + if face is None: + side = min(W, H) + cx, cy = W / 2, min(H / 2, side * 0.42) + else: + x, y, w, h = face + cx, cy = x + w / 2, y + h / 2 - h * 0.06 + side = min(max(w, h) * zoom, min(W, H)) + left = int(max(0, min(W - side, cx - side / 2))) + top = int(max(0, min(H - side, cy - side / 2))) + return (left, top, int(left + side), int(top + side)) + + def main(): ap = argparse.ArgumentParser() + ap.add_argument("--mode", default="headshot", + choices=["measure", "faces", "headshot", "cutout", "compose"]) ap.add_argument("--input", required=True) ap.add_argument("--output") ap.add_argument("--size", type=int, default=512) ap.add_argument("--cascade", default=None) - ap.add_argument("--radius", type=float, default=0.22, help="圓角半徑(佔邊長比例)") - ap.add_argument("--pick", default=None, help="largest|leftmost|rightmost|topmost|<由左至右的索引>") + ap.add_argument("--radius", type=float, default=0.22) + ap.add_argument("--pick", default=None) ap.add_argument("--face", default=None, help="直接指定臉的框 x,y,w,h") - ap.add_argument("--list", action="store_true", help="只列出偵測到的臉,不輸出圖") + ap.add_argument("--zoom", type=float, default=2.1, help="裁切框相對臉的倍率") + ap.add_argument("--bg", default=None, help="compose 的底色漸層,例如 #d9a45b,#c0392b") + ap.add_argument("--crop", default="head", choices=["head", "full"], + help="compose 時裁頭肩(預設)還是用整張") args = ap.parse_args() try: - from PIL import Image, ImageDraw + from PIL import Image except ImportError: fail("缺少 Pillow", "pip install pillow") try: - img = Image.open(args.input).convert("RGB") + img = Image.open(args.input) + img.load() except Exception as exc: fail(f"讀不到圖片:{exc}", "確認檔案完整;WebP 需要較新的 Pillow") W, H = img.size faces, method = detect_faces(args.input, args.cascade) + face = None + if args.face: + try: + face = tuple(int(v) for v in args.face.split(",")) + method = "manual-box" + except ValueError: + fail("--face 格式要是 x,y,w,h") + else: + face = choose(faces, args.pick) - if args.list: + if args.mode == "measure": + bg = background_report(img) + big = face[2] if face else 0 + has_alpha = False + if img.mode in ("RGBA", "LA", "PA"): + lo, hi = img.convert("RGBA").getchannel("A").getextrema() + has_alpha = lo < 16 and hi > 200 emit({ - "ok": True, - "size": [W, H], - "method": method, - "faces": [ - {"index": i, "x": f[0], "y": f[1], "w": f[2], "h": f[3], - "center": [f[0] + f[2] // 2, f[1] + f[3] // 2]} - for i, f in enumerate(sorted(faces, key=lambda f: f[0])) - ], + "ok": True, "size": [W, H], "pixels": W * H, "faces_found": len(faces), + "face": list(face) if face else None, "face_ratio": round(big / max(W, H), 3) if face else 0, + "method": method, "background": bg, "transparent": has_alpha, + "cutout_easy": bool(has_alpha or bg["plain"]), + }) + + if args.mode == "faces": + emit({ + "ok": True, "size": [W, H], "method": method, + "faces": [{"index": i, "x": f[0], "y": f[1], "w": f[2], "h": f[3], + "center": [f[0] + f[2] // 2, f[1] + f[3] // 2]} + for i, f in enumerate(sorted(faces, key=lambda f: f[0]))], }) if not args.output: - fail("需要 --output(或用 --list 只看偵測結果)") + fail("這個模式需要 --output") - if args.face: - try: - x, y, w, h = (int(v) for v in args.face.split(",")) - except ValueError: - fail("--face 格式要是 x,y,w,h") - method = "manual-box" - else: - chosen = choose(faces, args.pick) - if chosen: - x, y, w, h = chosen + if args.mode == "headshot": + from PIL import ImageDraw + box = head_box((W, H), face, args.zoom) + out = img.convert("RGB").crop(box).resize((args.size, args.size), Image.LANCZOS).convert("RGBA") + radius = int(args.size * args.radius) + mask = Image.new("L", (args.size, args.size), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, args.size - 1, args.size - 1], radius=radius, fill=255) + out.putalpha(mask) + out.save(args.output, "PNG", optimize=True) + emit({"ok": True, "mode": "headshot", "method": method, "faces_found": len(faces), + "source_size": [W, H], "face": list(face) if face else None, "box": list(box), + "size": args.size, "output": args.output}) + + if args.mode == "cutout": + bg = background_report(img) + # 最好的情況:官方人設圖多半本來就是透明底 PNG,直接沿用既有 alpha + existing = None + if img.mode in ("RGBA", "LA", "PA"): + a = img.convert("RGBA").getchannel("A") + lo, hi = a.getextrema() + if lo < 16 and hi > 200: + existing = a + if existing is not None: + alpha = existing + used = "source-alpha" + elif bg["plain"]: + alpha = alpha_from_plain_bg(img, bg["color"]) + used = "plain-background" else: - method = "heuristic-top-center" - side = min(W, H) - x, y, w, h = int(W / 2 - side * 0.25), int(min(H / 2, side * 0.30) - side * 0.25), \ - int(side * 0.5), int(side * 0.5) + alpha = alpha_from_grabcut(args.input, face) + used = "grabcut" + if alpha is None: + fail("這張圖的背景不是單色,而 OpenCV 不可用,無法去背", + "換一張官方人設圖(通常是白底),或安裝 opencv-python-headless<5") + rgba = img.convert("RGBA") + rgba.putalpha(alpha) + # 裁到實際內容的範圍,邊界不留大片透明 + bbox = rgba.getbbox() + if bbox: + rgba = rgba.crop(bbox) + rgba.save(args.output, "PNG", optimize=True) + hist = rgba.getchannel("A").histogram() + opaque = sum(hist[129:]) + emit({"ok": True, "mode": "cutout", "method": used, "background": bg, + "source_size": [W, H], "output_size": list(rgba.size), + "opaque_ratio": round(opaque / (rgba.size[0] * rgba.size[1]), 3), + "output": args.output}) - # 往外留邊,讓頭髮與肩膀進來一點,構圖才像頭像 - pad = max(w, h) * 0.55 - cx, cy = x + w / 2, y + h / 2 - h * 0.06 - side = min(max(w, h) + pad * 2, min(W, H)) - left = int(max(0, min(W - side, cx - side / 2))) - top = int(max(0, min(H - side, cy - side / 2))) - box = (left, top, int(left + side), int(top + side)) - - face = img.crop(box).resize((args.size, args.size), Image.LANCZOS).convert("RGBA") - radius = int(args.size * args.radius) - mask = Image.new("L", (args.size, args.size), 0) - ImageDraw.Draw(mask).rounded_rectangle([0, 0, args.size - 1, args.size - 1], radius=radius, fill=255) - face.putalpha(mask) - face.save(args.output, "PNG", optimize=True) - - emit({ - "ok": True, - "method": method, - "faces_found": len(faces), - "source_size": [W, H], - "face": [x, y, w, h], - "box": list(box), - "size": args.size, - "output": args.output, - }) + if args.mode == "compose": + from PIL import ImageDraw + cut = img.convert("RGBA") + S = args.size + colors = [(90, 110, 150), (40, 50, 80)] + if args.bg: + parts = [p.strip().lstrip("#") for p in args.bg.split(",")] + try: + colors = [tuple(int(p[i:i + 2], 16) for i in (0, 2, 4)) for p in parts[:2]] + except ValueError: + pass + if len(colors) == 1: + colors *= 2 + canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + draw = ImageDraw.Draw(canvas) + for i in range(S): + t = i / max(1, S - 1) + c = tuple(int(colors[0][k] + (colors[1][k] - colors[0][k]) * t) for k in range(3)) + draw.line([(0, i), (S, i)], fill=c + (255,)) + # 官方人設圖常是「正面+背面」兩張並排的設定稿,整張塞進去會變成兩個人。 + # 所以預設先裁到頭肩構圖(用臉的位置),要整張再指定 --crop full。 + if args.crop == "head": + cfaces, _ = detect_faces(args.input, args.cascade) + cface = choose(cfaces, args.pick) + if args.face: + try: + cface = tuple(int(v) for v in args.face.split(",")) + except ValueError: + pass + if cface is not None: + cut = cut.crop(head_box(cut.size, cface, args.zoom)) + cw, ch = cut.size + scale = (S * 0.98) / max(cw, ch) + nw, nh = max(1, int(cw * scale)), max(1, int(ch * scale)) + person = cut.resize((nw, nh), Image.LANCZOS) + canvas.alpha_composite(person, (int((S - nw) / 2), int((S - nh) / 2))) + radius = int(S * args.radius) + mask = Image.new("L", (S, S), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, S - 1, S - 1], radius=radius, fill=255) + out = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + out.paste(canvas, (0, 0), mask) + out.save(args.output, "PNG", optimize=True) + emit({"ok": True, "mode": "compose", "size": S, "person": [nw, nh], "output": args.output}) if __name__ == "__main__": diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 7ccacc1..ad17cbc 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -714,6 +714,39 @@ check("柵格器有做 bounding box 裁剪(1024 才跑得動)", (() => { return Date.now() - t0 < 4000; // 沒有裁剪的話會慢好幾倍 })()); +console.log("⑲ 找圖 → 去背 → 合成"); +check("找圖:官方設定稿加權高於一般截圖", (() => { + // wikiImageCandidates 的評分:解析度取 log2,命中 Full Body/Character Design 再加 8 + const big = { title: "File:Scene.png", width: 1920, height: 1080 }; + const sheet = { title: "File:Yui's ALO Pixie Form Full Body.png", width: 773, height: 1056 }; + const score = (r) => Math.round(Math.log2(r.width * r.height) * 10) / 10 + + (/full.?body|character.?design|concept|profile|settei|avatar/i.test(r.title) ? 8 : 0); + return score(sheet) > score(big); +})()); +check("cutout 的三條路徑都有實作", (() => { + const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8"); + return py.includes("source-alpha") && py.includes("plain-background") && py.includes("grabcut"); +})()); +check("portrait.py 有 measure/faces/headshot/cutout/compose 五個模式", (() => { + const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8"); + return ["measure", "faces", "headshot", "cutout", "compose"].every((m) => py.includes(`"${m}"`)); +})()); +check("compose 預設裁頭肩(官方設定稿常是正反兩面,整張會變兩個人)", (() => { + const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8"); + return py.includes('args.crop == "head"') && py.includes("head_box"); +})()); +check("去背圖路徑在 icon/(Wiki 區涵蓋)", + ic.CUTOUT_PNG === "icon/portrait-cutout.png" && covered("icon/portrait-cutout.png")[0] === "wiki"); +check("cutout 樣式的 SVG 內嵌同一張 PNG(自成一體、不外連)", (() => { + const spec = ic.iconSpec("GAMMA-01", { palette: ic.parsePalette("hair=#aaa,accent=#333") }); + const svg = ic.wrapPngSvg(spec, Buffer.from([0x89, 0x50, 0x4e, 0x47]), 64); + return svg.includes("data:image/png;base64,") && !svg.includes("http://example") && + svg.includes(" **鐵則:找到的圖片只能當底稿,不能當圖示。** -> 網路上的角色圖是別人的美術作品。這裡的做法是「裁出大頭照 → 看清楚 → -> **依人格資料重新繪製**」,產出的每一個像素都是本工具畫的。 +**流程**:找高解析度官方圖 → 量測 → **去背** → 裁頭肩 → 合成 → 更新頭像與 Wiki。 + +> 找不到可用的官方圖時(沒有設定稿、只有場景截圖且去背不乾淨), +> 才退回「依人格資料重新繪製」的向量形象(`--style portrait`)。 --- @@ -24,107 +25,78 @@ description: 產生或更新人格的形象圖(icon.svg + icon.png,人物頭 ### 1. 查出「最新一次登場」 -用 WebSearch 找這個角色最近的官方動向(新作、新章節、新造型),至少確認: +用 WebSearch 確認這個角色最近的官方動向(新作、新章節、新造型)與**對應的形態名稱** +(例:亞絲娜 → 2026《Unanswered//butterfly》重述早期艾恩葛朗特 → 對應 **SAO Avatar**; +結衣 → 現行 Unital Ring 章 → 對應 **ALO Pixie Form**)。 -- 最新的作品/章節名稱與年份 -- 那個版本的造型敘述(髮色、瞳色、服裝主色) - -搜尋語彙範例:`<角色> <作品> 2026 新作 主視覺`、` latest anime key visual outfit`。 - -### 2. 取得一張真的參考圖 - -要**直接的圖片網址**(`.png` / `.jpg`),優先序: - -1. 官方新作的主視覺/key visual(新聞稿內的圖) -2. AniList 的官方角色圖——可直接查,不需登入: - - ```bash - curl -s -X POST -H "Content-Type: application/json" \ - -d '{"query":"query{Media(id:<動畫 id>,type:ANIME){characters(perPage:25){nodes{name{full} image{large}}}}}"}' \ - https://graphql.anilist.co - ``` - -3. 其他公開的官方素材 - -下載下來:`curl -sL -o /tmp/ref.png "<圖片網址>"` - -### 3. **親眼看過那張圖**(不可省略) - -用 Read 工具打開它。**沒看過就不准填顏色**——憑記憶填色等於編造。 - -從圖上讀出五個顏色(用取樣到的實際色,不是「大概是紅色」): - -| 欄位 | 取哪裡 | -| --- | --- | -| `hair` | 頭髮的主色(不是高光也不是陰影) | -| `eye` | 瞳孔顏色 | -| `accent` | 服裝最顯眼的主色 | -| `secondary` | 服裝的第二色/配件色 | -| `light` | 最亮的部位(白袖、皮膚亮面) | - -### 4. 裁出大頭照當底稿 +### 2. 找高解析度官方圖 ```bash -# 先看看圖裡有幾張臉——多角色的主視覺一定要先看 -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon faces --session --photo "<圖片網址或路徑>" - -# 裁出底稿(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框) -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon headshot --session --photo "<圖片網址或路徑>" --pick <索引> +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon search --session --wiki --page <角色頁名> ``` -底稿寫到 `<人格>/.sync/headshot.png`,**不是圖示、不會同步、不會發佈**。 +結果依「解析度 + 是不是官方設定稿」排序,標 📐 的是設定稿(`Full Body`/`Character Design`/ +`Avatar`)。**優先選設定稿**: -**多角色的圖務必自己看過再挑**:`icon faces` 只給座標,哪張臉是本人要由你判斷。 -裁完**一定要用 Read 打開確認是本人**——挑錯就是別人的臉。 +- 解析度高(常見 773×1056、1280×880),遠勝角色資料庫的 230px 縮圖 +- **多半是透明底或純白底 → 去背幾乎免費、邊緣乾淨** +- 是官方繪製的正式立繪,不是動畫截圖 -工具不齊時 CLI 會**印出安裝指令**——不會靜默失敗。需要的工具: +挑「與第 1 步的形態相符」的那一張——同一個角色會有很多套造型。 -| 工具 | 做什麼 | 怎麼裝(免 sudo) | -| --- | --- | --- | -| Pillow | 解碼照片、裁切、縮放 | `python3 -m venv ~/.cache/jsc-persona/venv && ~/.cache/jsc-persona/venv/bin/pip install pillow` | -| OpenCV 4.x | 自動找臉的位置 | `~/.cache/jsc-persona/venv/bin/pip install "opencv-python-headless<5"` | -| 動漫臉模型 | OpenCV 內建模型認不出動漫臉 | `curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml` | - -> OpenCV **5.x 拿掉了 `CascadeClassifier`**,一定要裝 4.x。 -> venv 放在 `~/.cache/jsc-persona/venv` 會被自動偵測到;也可以用 `PERSONA_PYTHON` 指定別的。 - -### 5. 看著底稿,把特徵讀出來 - -用 Read 打開 `.sync/headshot.png`,**同時**讀這個人格的 `IDENTITY.md`/`SOUL.md`/canon 記憶。 -形象要同時對得起「他長什麼樣」與「他是誰」: - -| 特徵 | 可選值 | 從哪裡判斷 | -| --- | --- | --- | -| `hairstyle` | straight / twintails / ponytail / bob / braid | 底稿的髮型 | -| `length` | short / medium / long / very-long | 底稿的髮長 | -| `fringe` | blunt / parted / swept / curtain | 底稿的瀏海 | -| `eyes` | round / almond / sharp / droopy | 底稿的眼型 | -| `expression` | gentle / bright / calm / neutral | **SOUL 的 Vibe** + 底稿的神情 | -| `accessory` | none / ribbon / clip / flower / hairband | 底稿或最新造型的髮飾 | -| `side` | left / right | 髮飾在哪一側 | -| `collar` | round / v / high / sailor | 服裝領口 | -| `ahoge` | yes / no | 有沒有呆毛 | - -### 6. 重新繪製 +### 3. 量測 ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force \ - --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3,skin=#f7ddc4" \ - --features "hairstyle=straight,length=very-long,fringe=parted,eyes=almond,expression=calm,accessory=none,collar=v,ahoge=yes" \ - --source-url "<底稿那張圖的網址>" \ - --source-note "<作品(年份)+底稿是誰+重繪依據:髮型、眼型、神情、服裝>" \ - --source-date +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon measure --session --photo "<圖片網址>" ``` -**畫完一定要用 Read 打開產出的 `icon.png` 看一眼**,不像就調特徵重畫(`--force`)。 +會回報解析度、找到幾張臉、臉佔長邊多少、背景是**透明/單色/有場景**、去背難不難。 +`去背難度:偏難` 就回第 2 步換一張設定稿,不要硬做。 -- **`--palette` 一定要配 `--source-url`**:配色是從哪張圖來的必須留得下來(CLI 會強制)。 -- 兩者都會寫進 `state/config.json` 的 `icon` 欄位,之後 `icon show` 查得到。 -- 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像、同步到 Wiki 區, - 並在 Wiki 產生一頁 **Icon**(同時展示 SVG 與 PNG,附上來源網址與裁切框)。 +### 4. 去背 -找不到可靠的參考圖 → **不要硬掰**,直接 `icon generate` 不帶 `--palette`, -用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。 +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon cutout --session --photo "<圖片網址>" [--pick <索引>] +``` + +三條路徑,自動選最好的那條: + +| 方式 | 什麼時候 | 品質 | +| --- | --- | --- | +| `source-alpha` | 原圖本來就是透明底(官方設定稿常見) | 完美 | +| `plain-background` | 純白/單色底 | 很好 | +| `grabcut` | 有場景,用臉的位置當前景種子 | 普通,邊緣可能不乾淨 | + +輸出到 `icon/portrait-cutout.png`。**用 Read 打開確認**:邊緣有沒有殘留、頭髮有沒有被切掉。 +黑髮角色在有場景的圖上特別容易被 GrabCut 誤切——真的不行就換設定稿。 + +### 5. 合成圖示 + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force --from-cutout [--zoom 1.85] [--pick <索引>] \ + --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.." \ + --source-url "<那張圖的網址>" --source-note "<作品/形態+為什麼選這張>" +``` + +- 自動裁成**頭肩構圖**(官方設定稿常是正反兩面並排,不裁會變成兩個人;`--zoom` 調鬆緊)。 +- 底色漸層取自 `--palette`(`light` → `accent`),所以要先看過圖再填顏色。 +- 產出 `icon.svg`(內嵌同一張 PNG,自成一體不外連)、`icon.png`,以及 + `icon/portrait.svg`、`icon/portrait-512.png`、`icon/portrait-1024.png`。 +- **畫完用 Read 打開看一眼**,構圖不好就調 `--zoom` 或 `--face` 重來。 + +### 6. 沒有官方圖時:向量重繪(有臉) + +裁出大頭照當底稿、親眼看過、讀出特徵,再重繪: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon headshot --session --photo "<圖片網址>" --pick <索引> # 底稿,不同步 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force --palette "..." --features "hairstyle=..,eyes=..,expression=.." +``` + +特徵可選值:`hairstyle` straight/twintails/ponytail/bob/braid、`length` short/medium/long/very-long、 +`fringe` blunt/parted/swept/curtain、`eyes` round/almond/sharp/droopy、 +`expression` gentle/bright/calm/neutral、`accessory` none/ribbon/clip/flower/hairband、 +`side` left/right、`collar` round/v/high/sailor、`ahoge` yes/no。 ### 7. 回報