feat: 人格編號、Gitea 儲存(檔案區/Wiki 區)與人格圖示 (v0.0.4) #3

Merged
admin merged 8 commits from develop into master 2026-07-30 03:57:40 +00:00
11 changed files with 613 additions and 217 deletions
Showing only changes of commit 929ccb3c05 - Show all commits
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
"version": "0.0.8",
"version": "0.0.9",
"description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
"skills": "./skills"
}
+6 -6
View File
@@ -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 不得有 `<image>`base64/外連)
**沒看過圖就不准填顏色或特徵、也不准亂挑臉**(多角色先 `icon faces` 再挑,挑完打開確認)
12. **形象圖優先用「高解析度官方圖去背」**`icon search``icon measure``icon cutout`
`icon generate --from-cutout`。找圖時**優先官方設定稿**Full BodyCharacter Design
Avatar):解析度高,而且多半是透明底或白底,去背幾乎免費。挑的那張要**對得上該人格
「最新一次登場」的形態**(同一個角色有很多套造型)。
每一步都要**用 Read 打開確認**:去背有沒有殘留、構圖對不對
找不到可用官方圖才退回 `--features` 的向量重繪
13. **選用工具缺了要「提示安裝」,不准靜默降級**`toolReport()` 會列出缺什麼、為什麼要、
怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**
plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。
+12 -17
View File
@@ -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 裡沒有 `<image>`、沒有 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 `<img>` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` |
---
@@ -150,29 +150,24 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
建立人格**並補齊 IDENTITYSOUL 之後**產生,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 <sub> --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 沒有 `<image>`、沒有 base64、沒有外連,每個像素都是畫出來的。
@@ -389,7 +384,7 @@ node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
node scripts/persona.mjs sync status --session <id> # 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)、
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
"version": "0.0.8",
"version": "0.0.9",
"description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills/"
}
+2 -1
View File
@@ -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 {
+119 -16
View File
@@ -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);
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 依人格資料重新繪製。
+105 -6
View File
@@ -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 BodyCharacter 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,18 +1308,32 @@ 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.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>
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 區。
+262 -69
View File
@@ -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 <out.png> [--size 512]
[--cascade <xml>] [--pick largest|leftmost|rightmost|<index>]
[--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],
"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]))
],
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
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)
# 往外留邊,讓頭髮與肩膀進來一點,構圖才像頭像
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")
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)
face.putalpha(mask)
face.save(args.output, "PNG", optimize=True)
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})
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 == "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:
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})
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__":
+33
View File
@@ -714,6 +714,39 @@ check("柵格器有做 bounding box 裁剪(1024 才跑得動)", (() => {
return Date.now() - t0 < 4000; // 沒有裁剪的話會慢好幾倍
})());
console.log("⑲ 找圖 → 去背 → 合成");
check("找圖:官方設定稿加權高於一般截圖", (() => {
// wikiImageCandidates 的評分:解析度取 log2,命中 Full BodyCharacter 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 有 measurefacesheadshotcutoutcompose 五個模式", (() => {
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("<image");
})());
check("沒有去背圖時 --from-cutout 會擋下並指引流程",
cli(["icon", "generate", "--session", S_CODE, "--force", "--from-cutout"],
{ expectOk: false }).status !== 0);
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
process.exit(failed ? 1 : 0);
+63 -91
View File
@@ -1,6 +1,6 @@
---
name: persona-icon
description: 產生或更新人格的形象圖(icon.svg + icon.png,人物頭像、看得到臉):先上網找出該人格「最新一次登場」的官方視覺,裁出**大頭照當底稿**,親眼看過之後萃取髮色/瞳色/服裝色與髮型、眼型、表情、配件等特徵,再**由本工具依人格資料重新繪製**——絕不把找到的圖片直接當圖示。來源網址、底稿裁切框與重繪依據都會存進 config 備查,圖示同步到 Gitea Wiki(SVG + PNG)並設為存取庫頭像,且會回頭驗證同步結果。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說現在的圖示不像或看不到臉、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-createpersona-anime,它們會叫用本技能的步驟)、同步到 Giteapersona-sync)。
description: 產生或更新人格的形象圖(icon.svg + icon.png):先找出該人格「最新一次登場」對應的**高解析度官方圖**(優先官方人設稿/設定圖,它們通常是透明底或白底、解析度遠高於資料庫縮圖),量測解析度與臉的大小後**去背**成透明 PNG,再裁成頭肩構圖、合成到取自角色配色的漸層底上,成為存取庫頭像並同步到 Gitea Wiki(含 icon/ 多解析度)。找不到可用的官方圖時,退回依人格資料重新繪製的向量人物形象。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說圖示不像或不夠清楚、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-createpersona-anime)、同步到 Giteapersona-sync)。
---
# 🎨 persona-icon — 依最新造型產生人格形象圖(有臉)
@@ -9,9 +9,10 @@ description: 產生或更新人格的形象圖(icon.svg + icon.png,人物頭
**時機****人格建立且 IDENTITY/SOUL 補齊之後**——形象綁在最終身分上。
**目標**:圖示要是**人物形象圖,看得到臉**;退而求其次才是抽象徽章。
> **鐵則:找到的圖片只能當底稿,不能當圖示。**
> 網路上的角色圖是別人的美術作品。這裡的做法是「裁出大頭照 → 看清楚 →
> **依人格資料重新繪製**」,產出的每一個像素都是本工具畫的。
**流程**:找高解析度官方圖 → 量測 → **去背** → 裁頭肩 → 合成 → 更新頭像與 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 新作 主視覺``<character> 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 <PERSONA_SESSION> --photo "<圖片網址或路徑>"
# 裁出底稿(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon headshot --session <PERSONA_SESSION> --photo "<圖片網址或路徑>" --pick <索引>
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon search --session <PERSONA_SESSION> --wiki <fandom 子網域> --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 <PERSONA_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 <YYYY-MM-DD>
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon measure --session <PERSONA_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 <PERSONA_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 <PERSONA_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 <PERSONA_SESSION> --photo "<圖片網址>" --pick <索引> # 底稿,不同步
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session <PERSONA_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. 回報