feat: 圖示改為人物形象圖(有臉),並讓 Wiki 保存 SVG + PNG

1. 圖示三種樣式,優先看得到臉
   photo    有參考圖且工具齊全 → 從官方視覺自動偵測並**裁出臉**,圓角+瞳色外框
   portrait 有參考圖但缺工具   → **有五官的向量人物**(髮型/瞳色/服裝色都取自那張圖)
   badge    完全沒有參考圖     → 舊的雙色漸層 + 編號字母

   portrait 與 badge 共用同一份「圖形清單」,SVG 與自寫柵格器從同一份資料畫,
   兩邊不可能長得不一樣;photo 的 SVG 以 base64 內嵌裁好的 PNG,一樣不外連。

2. 缺工具會「提示安裝」,不靜默降級
   新增 scripts/portrait.py(選用):Pillow 解碼裁切、OpenCV + lbpcascade_animeface
   偵測動漫臉。toolReport() 會列出缺什麼、為什麼要、怎麼裝(venv 免 sudo),
   CLI 在退回向量形象時把這些印出來。
   注意:**OpenCV 5 拿掉了 CascadeClassifier,必須裝 4.x**。
   plugin 本體仍然零依賴——沒有這些工具照樣產得出有臉的形象圖。

   新增 `icon faces`:列出參考圖裡偵測到的所有臉。多角色的主視覺一定要先看再挑
   (--pick <索引>|largest|leftmost|rightmost 或 --face x,y,w,h),挑錯就是別人的臉。

3. Wiki 保存形象圖
   icon.svg 與 icon.png 都在 Wiki 區,另外自動產生一頁 Icon:同時展示兩種格式,
   並列出樣式、調色盤、來源網址、造型說明與裁切框。PNG 同時設為存取庫頭像。

已套用到兩個真實人格(皆為真實裁臉):
  ASUNA-01  《Unanswered//butterfly》(2026) 官方主視覺,臉 #1(anime-cascade)
  YUI-01    AniList 官方角色圖(frontal-cascade)

selftest 148 項全綠(新增第 ⑯ 節:形象圖真的畫了五官、SVG/PNG 出自同一份清單、
工具偵測與安裝提示、Wiki 形象圖專頁與來源)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:39:58 +00:00
co-authored by Claude Opus 5
parent 1884a9c3d7
commit 15e4eea8ac
11 changed files with 769 additions and 91 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.0.5", "version": "0.0.6",
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.0.5", "version": "0.0.6",
"description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
"skills": "./skills" "skills": "./skills"
} }
+9 -4
View File
@@ -34,10 +34,15 @@
**同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST``GITEA_TOKEN` 就純本機運作。 **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST``GITEA_TOKEN` 就純本機運作。
11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), 11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字),
PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。
12. **圖示配色優先取自「最新登場」的官方視覺**`/jsc-persona:persona-icon`): 12. **圖示必須是人物形象圖(盡量有臉)**,來源是該人格「最新一次登場」的官方視覺
上網找圖 → **用 Read 親眼看過** → 取色 → `--palette` 搭配 `--source-url`CLI 強制存證)。 `/jsc-persona:persona-icon`):上網找圖 → **用 Read 親眼看過** → 有 PillowOpenCV 就
**沒看過圖就不准填顏色**;找不到官方圖就退回編號雜湊配色並告知使用者 `--photo` 裁臉,否則畫有五官的向量人物;配色一律 `--palette` 搭配 `--source-url`CLI 強制存證)
不把原圖放進存取庫:那是散布他人美術作品,而且環境裡也沒有解碼器可用 **沒看過圖就不准填顏色、也不准亂挑臉**(多角色的圖先 `icon faces` 再挑,挑完打開確認)
13. **選用工具缺了要「提示安裝」,不准靜默降級**`toolReport()` 會列出缺什麼、為什麼要、
怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**
plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。
14. **Wiki 必須保存形象圖**`icon.svg``icon.png` 都在 Wiki 區,另有自動產生的 **Icon**
展示兩種格式並列出來源、樣式與裁切框。
## 慣例 ## 慣例
+33 -19
View File
@@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
--- ---
## 十條硬規則 ## 十條硬規則
| 規則 | 怎麼做到 | | 規則 | 怎麼做到 |
| --- | --- | | --- | --- |
@@ -26,7 +26,8 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01``YUI-01``ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01``YUI-01``ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
| **12. 建立並補齊資料後產生圖示,配色取自最新造型** | `/jsc-persona:persona-icon` 上網找出該人格**最新一次登場**的官方視覺、**親眼看過**那張圖,取出髮色/瞳色/服裝色,再繪製 `icon.svg` + `icon.png`512×512),來源網址一併存進 `config.json` 備查;沒有參考圖才退回用編號雜湊配色。PNG 是**自寫柵格器 + zlib 手工編碼**,不依賴任何影像函式庫 | | **12. 圖示是人物形象圖,取自最新登場的官方視覺** | `/jsc-persona:persona-icon` 上網找出該人格**最新一次登場**的官方視覺、**親眼看過**,有 PillowOpenCV 就直接**裁出臉**當形象圖;沒工具則畫**有五官的向量人物**(配色同樣取自那張圖),並印出安裝指令。來源網址與裁切框存進 `config.json` 備查 |
| **13. Wiki 保存形象圖** | `icon.svg``icon.png` 一起同步到 Wiki 區,另外自動產生一頁 **Icon** 同時展示兩種格式並列出來源、樣式與裁切框;PNG 同時設為存取庫頭像 |
--- ---
@@ -146,11 +147,27 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
## 人格圖示(SVG + PNG,零外部依賴) ## 人格圖示(SVG + PNG,零外部依賴)
建立人格**並補齊 IDENTITYSOUL 之後**產生,一張 512×512 的幾何徽章。配色有兩種來源: 建立人格**並補齊 IDENTITYSOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**,
依環境有無工具分三種樣式:
**① 取自最新造型(`/jsc-persona:persona-icon`,動漫角色建議用這個)** | 樣式 | 條件 | 長什麼樣 |
| --- | --- | --- |
| **`photo`** | 有參考圖 **且** 有 PillowOpenCV | 從官方視覺**裁出的臉**(自動臉部偵測),圓角+瞳色外框 |
| **`portrait`** | 有參考圖但缺工具 | **有五官的向量人物**:髮型、瞳色、服裝色都取自那張圖 |
| **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 |
上網查出角色**最新一次登場**的官方視覺 → 下載 → **親眼看過** → 取五個色 → 繪製 缺工具時 CLI **會印出安裝指令**再退回 `portrait`,不會靜默降級
```bash
python3 -m venv ~/.cache/jsc-persona/venv
~/.cache/jsc-persona/venv/bin/pip install pillow "opencv-python-headless<5" # OpenCV 5 拿掉了 CascadeClassifier
curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml \
https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml
```
(venv 放這個路徑會被自動偵測;也可用 `PERSONA_PYTHON` 指定。)
**配色一律取自實際看過的參考圖**
| 元素 | 取自照片的哪裡 | | 元素 | 取自照片的哪裡 |
| --- | --- | | --- | --- |
@@ -163,13 +180,7 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
兩個顏色一深一淺時(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓到對比 ≥ 3.2, 兩個顏色一深一淺時(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓到對比 ≥ 3.2,
確保字讀得到——顏色仍然是照片來的,只是收斂色階。 確保字讀得到——顏色仍然是照片來的,只是收斂色階。
**② 沒有參考圖時:編號雜湊** 沒有參考圖時(原創人格)才退回雜湊配色:`sha256(編號|NameEmoji)` 決定漸層與點陣紋。
| 元素 | 怎麼決定 |
| --- | --- |
| 雙色對角漸層 | `sha256(編號|NameEmoji)` → 色相/飽和度/明度 |
| 5×5 左右對稱點陣紋 | 同一組雜湊的位元 |
| 中央兩個字母 | 編號的前兩個字,自動選黑或白確保對比 |
- **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01``ASUNA-02` 明顯不同。 - **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01``ASUNA-02` 明顯不同。
- SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。 - SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。
@@ -180,17 +191,20 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
rsvgimagemagickPillowffmpeg 都沒有),JPEG/WebP 讀不進來,無法把原圖轉成 PNG; rsvgimagemagickPillowffmpeg 都沒有),JPEG/WebP 讀不進來,無法把原圖轉成 PNG;
而且把他人的美術作品原樣放進存取庫是散布、不是引用。**取配色**是有依據又不搬運原圖的做法。 而且把他人的美術作品原樣放進存取庫是散布、不是引用。**取配色**是有依據又不搬運原圖的做法。
emoji 同理無法柵格化,但仍參與雜湊配色。 emoji 同理無法柵格化,但仍參與雜湊配色。
- 圖示屬於低頻資料 → 同步到 **Wiki 區**,並自動設成 Gitea **存取庫頭像** - 圖示屬於低頻資料 → `icon.svg``icon.png`同步到 **Wiki 區**,並自動產生一頁 **Icon**
展示兩種格式與來源;PNG 同時設成 Gitea **存取庫頭像**
```bash ```bash
# 取自最新造型(必須同時給來源網址 # 先看參考圖裡有幾張臉(多角色的主視覺務必先看
node scripts/persona.mjs icon faces --session <id> --photo "<圖片網址>"
# 裁臉 + 取色(--palette 必須配 --source-url
node scripts/persona.mjs icon generate --session <id> --force \ node scripts/persona.mjs icon generate --session <id> --force \
--photo "<圖片網址>" --pick 1 \
--palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \
--source-url "<官方視覺的網址>" --source-note "<作品(年份)+造型描述>" --source-url "<官方視覺的網址>" --source-note "<作品(年份)+造型描述>"
# 沒有參考圖:編號雜湊配色 node scripts/persona.mjs icon show --session <id> # 看樣式、配色、來源與裁切框
node scripts/persona.mjs icon generate --session <id> [--size 512] [--force]
node scripts/persona.mjs icon show --session <id> # 看配色與來源
``` ```
## 人格編號與 Gitea 儲存 ## 人格編號與 Gitea 儲存
@@ -355,11 +369,11 @@ node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用) node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態 node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
node scripts/selftest.mjs # 136 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示與照片配色、hooks node scripts/selftest.mjs # 148 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、形象圖與裁臉、hooks
``` ```
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、
`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(圖SVG + 自寫 PNG 編碼)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 `scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`形象圖:SVG + 自寫 PNG 編碼)、`scripts/portrait.py`(選用:照片裁臉)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.0.5", "version": "0.0.6",
"description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills/" "skills": "./skills/"
} }
+62 -3
View File
@@ -306,7 +306,7 @@ function listTrackedFiles(dir) {
} }
export const WIKI_MANIFEST = "_paths.json"; export const WIKI_MANIFEST = "_paths.json";
const WIKI_RESERVED = new Set(["Home.md", WIKI_MANIFEST]); const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]);
const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" }; const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" };
/** /**
@@ -389,6 +389,58 @@ function unstageArea(slug, area, dir, only = null) {
return written; return written;
} }
/**
* Wiki 的「形象圖」頁:把 icon.svg 與 icon.png 都保存在 Wiki 並展示出來,
* 附上這張圖是從哪張參考照片來的(可查證)。
*/
export function wikiIconPage(slug, code) {
const ident = pl.identityFields(slug);
const config = pl.loadConfig(slug);
const icon = config.icon || {};
const src = icon.source || {};
const root = pl.personaDir(slug);
const hasSvg = fs.existsSync(path.join(root, "icon.svg"));
const hasPng = fs.existsSync(path.join(root, "icon.png"));
const styleLabel = {
photo: "真實照片裁臉",
portrait: "向量人物形象(有臉),配色取自參考照片",
badge: "編號徽章(沒有參考照片時的樣式)",
}[icon.style || "badge"];
return [
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} 的形象圖`,
"",
`\`${code}\` ${styleLabel}`,
"",
...(hasSvg || hasPng
? [
"| SVG(向量,可無限放大) | PNG(點陣,Gitea 頭像用) |",
"| --- | --- |",
`| ${hasSvg ? '<img src="icon.svg" alt="icon.svg" width="200">' : "(缺)"} ` +
`| ${hasPng ? '<img src="icon.png" alt="icon.png" width="200">' : "(缺)"} |`,
`| [icon.svg](icon.svg) | [icon.png](icon.png) |`,
"",
]
: ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]),
"## 這張圖怎麼來的",
"",
"| 欄位 | 內容 |",
"| --- | --- |",
`| 樣式 | ${icon.style || "badge"} |`,
`| 尺寸 | ${icon.size || "?"}×${icon.size || "?"} |`,
`| 調色盤 | ${icon.palette || "(由編號雜湊)"} |`,
`| 參考來源 | ${src.url ? `[${src.url}](${src.url})` : "(無)"} |`,
`| 造型說明 | ${src.note || "—"} |`,
`| 參考日期 | ${src.date || "—"} |`,
`| 產生時間 | ${icon.generated_at || "—"} |`,
"",
icon.style === "photo"
? "> 由 jsc-persona 從上述參考圖**裁出臉部**產生(自動臉部偵測),並套上圓角與瞳色外框。"
: "> 由 jsc-persona 產生:配色取自角色**最新一次登場**的官方視覺。",
"> 這裡保存的是產生出來的形象圖(SVG + PNG),供人格身分辨識使用。",
"",
].join("\n");
}
/** Wiki 首頁:讓 Gitea 上點進去就看得懂這是誰。 */ /** Wiki 首頁:讓 Gitea 上點進去就看得懂這是誰。 */
export function wikiHome(slug, code) { export function wikiHome(slug, code) {
const ident = pl.identityFields(slug); const ident = pl.identityFields(slug);
@@ -417,6 +469,7 @@ export function wikiHome(slug, code) {
"- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar", "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar",
"- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity", "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity",
"- [AGENTS](AGENTS) — 操作規則  [USER](USER) — 對使用者的理解", "- [AGENTS](AGENTS) — 操作規則  [USER](USER) — 對使用者的理解",
"- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源",
"- [Memory INDEX](Memory-INDEX) — 長期記憶索引", "- [Memory INDEX](Memory-INDEX) — 長期記憶索引",
"", "",
"### 長期記憶", "### 長期記憶",
@@ -466,7 +519,10 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
const theOwner = owner || (await resolveOwner()); const theOwner = owner || (await resolveOwner());
const { host } = giteaEnv(); const { host } = giteaEnv();
const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
if (area === "wiki") pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); if (area === "wiki") {
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
}
const staged = stageArea(slug, area, dir); const staged = stageArea(slug, area, dir);
gitOrThrow(["add", "-A"], dir, "git add"); gitOrThrow(["add", "-A"], dir, "git add");
const dirty = git(["diff", "--cached", "--quiet"], dir); const dirty = git(["diff", "--cached", "--quiet"], dir);
@@ -485,7 +541,10 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) { if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) {
git(["reset", "--hard", "--quiet", `origin/${branch}`], dir); git(["reset", "--hard", "--quiet", `origin/${branch}`], dir);
stageArea(slug, area, dir); stageArea(slug, area, dir);
if (area === "wiki") pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); if (area === "wiki") {
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
}
git(["add", "-A"], dir); git(["add", "-A"], dir);
if (!git(["diff", "--cached", "--quiet"], dir).ok) { if (!git(["diff", "--cached", "--quiet"], dir).ok) {
git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir); git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir);
+319 -33
View File
@@ -15,9 +15,12 @@
// 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。 // 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import zlib from "node:zlib"; import zlib from "node:zlib";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import * as pl from "./persona-lib.mjs"; import * as pl from "./persona-lib.mjs";
export const ICON_SVG = "icon.svg"; export const ICON_SVG = "icon.svg";
@@ -148,7 +151,7 @@ function harmonize(c1, c2, target = 3.2) {
// 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。 // 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
// 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 config.json 備查。 // 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 config.json 備查。
export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light"]; export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light", "skin"];
export function parseHexColor(value) { export function parseHexColor(value) {
const m = String(value ?? "").trim().match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i); const m = String(value ?? "").trim().match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i);
@@ -172,13 +175,16 @@ export function parsePalette(raw) {
out.eye ??= out.accent; out.eye ??= out.accent;
out.secondary ??= out.accent; out.secondary ??= out.accent;
out.light ??= [246, 240, 236]; out.light ??= [246, 240, 236];
out.skin ??= mix(out.light, [242, 201, 168], 0.65); // 沒指定就用偏暖的膚色
return out; return out;
} }
export const paletteToString = (p) => export const paletteToString = (p) =>
PALETTE_KEYS.filter((k) => p?.[k]).map((k) => `${k}=${hex(p[k])}`).join(","); PALETTE_KEYS.filter((k) => p?.[k]).map((k) => `${k}=${hex(p[k])}`).join(",");
export function iconSpec(slug, { code = null, identity = null, palette = null } = {}) { export const STYLES = ["portrait", "badge"];
export function iconSpec(slug, { code = null, identity = null, palette = null, style = null } = {}) {
const ident = identity || pl.identityFields(slug); const ident = identity || pl.identityFields(slug);
const config = pl.loadConfig(slug); const config = pl.loadConfig(slug);
const theCode = code || config.code || slug; const theCode = code || config.code || slug;
@@ -238,6 +244,8 @@ export function iconSpec(slug, { code = null, identity = null, palette = null }
dot: dot || ink, dot: dot || ink,
ringAlpha, ringAlpha,
dotAlpha, dotAlpha,
// 有調色盤(=看過參考照片)就畫人物形象;沒有的話只能畫徽章
style: style || config.icon?.style || (pal ? "portrait" : "badge"),
palette: pal, palette: pal,
source: pal ? config.icon?.source || null : null, source: pal ? config.icon?.source || null : null,
pattern, pattern,
@@ -273,6 +281,82 @@ function dots(spec) {
return out; return out;
} }
// --------------------------------------------------------------------------- //
// 圖形清單:SVG 與柵格器都從這份清單畫,兩邊不可能長得不一樣
// --------------------------------------------------------------------------- //
const shade = (c, t) => mix(c, [0, 0, 0], t);
const tint = (c, t) => mix(c, [255, 255, 255], t);
const rect = (x, y, w, h, fill, alpha = 1) => ({ type: "rect", x, y, w, h, fill, alpha });
const ellipse = (cx, cy, rx, ry, fill, alpha = 1) => ({ type: "ellipse", cx, cy, rx, ry, fill, alpha });
const circle = (cx, cy, r, fill, alpha = 1) => ellipse(cx, cy, r, r, fill, alpha);
/**
* 人物形象(有臉)。全部用橢圓與矩形拼出來,所以 SVG 與自寫柵格器畫得出一模一樣的結果。
* 顏色一律來自參考照片萃取的調色盤:髮色、瞳色、服裝色、膚色。
*/
function portraitShapes(spec) {
const p = spec.palette || {};
const hair = p.hair || spec.c1;
const eye = p.eye || spec.ring;
const cloth = p.accent || spec.c2;
const cloth2 = p.secondary || cloth;
const skin = p.skin || [244, 214, 187];
const hairDark = shade(hair, 0.32);
const skinShade = shade(skin, 0.12);
const mouth = shade(cloth, 0.25);
return [
// 肩膀與衣服(露出一截,看得出服裝主色)
ellipse(0.5, 1.18, 0.47, 0.36, cloth),
ellipse(0.5, 1.14, 0.175, 0.22, cloth2), // 領口/胸前配色
// 脖子
rect(0.442, 0.64, 0.116, 0.14, skinShade),
// 後髮(長髮往下鋪到兩側)
ellipse(0.5, 0.50, 0.315, 0.395, hairDark),
ellipse(0.235, 0.78, 0.080, 0.27, hairDark),
ellipse(0.765, 0.78, 0.080, 0.27, hairDark),
// 臉
ellipse(0.5, 0.505, 0.212, 0.248, skin),
// 瀏海:中央一大片 + 兩側鬢髮,蓋住額頭
ellipse(0.5, 0.335, 0.238, 0.158, hair),
ellipse(0.312, 0.435, 0.072, 0.155, hair),
ellipse(0.688, 0.435, 0.072, 0.155, hair),
// 眉毛
rect(0.362, 0.452, 0.095, 0.015, hairDark),
rect(0.543, 0.452, 0.095, 0.015, hairDark),
// 眼睛:眼白 → 虹膜 → 瞳孔 → 高光
ellipse(0.415, 0.538, 0.060, 0.073, [252, 252, 255]),
ellipse(0.585, 0.538, 0.060, 0.073, [252, 252, 255]),
ellipse(0.415, 0.543, 0.046, 0.059, eye),
ellipse(0.585, 0.543, 0.046, 0.059, eye),
ellipse(0.415, 0.549, 0.021, 0.030, shade(eye, 0.7)),
ellipse(0.585, 0.549, 0.021, 0.030, shade(eye, 0.7)),
circle(0.399, 0.520, 0.015, [255, 255, 255]),
circle(0.569, 0.520, 0.015, [255, 255, 255]),
// 腮紅與嘴
ellipse(0.330, 0.598, 0.048, 0.025, cloth, 0.28),
ellipse(0.670, 0.598, 0.048, 0.025, cloth, 0.28),
ellipse(0.5, 0.626, 0.026, 0.015, mouth),
// 側邊髮飾(用第二配色,讓不同角色更好分辨)
ellipse(0.762, 0.345, 0.056, 0.038, cloth2),
ellipse(0.800, 0.375, 0.030, 0.052, cloth2),
];
}
/** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */
function badgeShapes(spec) {
return [
...dots(spec).map((d) => circle(d.cx, d.cy, d.r, spec.dot, spec.dotAlpha)),
...glyphRects(spec).map((r) => rect(r.x, r.y, r.w, r.h, spec.ink)),
];
}
export function iconShapes(spec) {
return spec.style === "portrait" ? portraitShapes(spec) : badgeShapes(spec);
}
/** 回傳字母的所有方塊(單位座標)。 */ /** 回傳字母的所有方塊(單位座標)。 */
function glyphRects(spec) { function glyphRects(spec) {
const cell = GEO.glyphCell; const cell = GEO.glyphCell;
@@ -309,13 +393,18 @@ export function renderSvg(spec, size = DEFAULT_SIZE) {
` <stop offset="0" stop-color="${hex(spec.c1)}"/>`, ` <stop offset="0" stop-color="${hex(spec.c1)}"/>`,
` <stop offset="1" stop-color="${hex(spec.c2)}"/>`, ` <stop offset="1" stop-color="${hex(spec.c2)}"/>`,
" </linearGradient>", " </linearGradient>",
` <clipPath id="badge">`,
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}"/>`,
" </clipPath>",
" </defs>", " </defs>",
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`, ` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`,
` <g fill="${hex(spec.dot)}" opacity="${spec.dotAlpha}">`, ` <g clip-path="url(#badge)">`,
...dots(spec).map((d) => ` <circle cx="${u(d.cx)}" cy="${u(d.cy)}" r="${u(d.r)}"/>`), ...iconShapes(spec).map((sh) =>
" </g>", sh.type === "rect"
` <g fill="${hex(spec.ink)}">`, ? ` <rect x="${u(sh.x)}" y="${u(sh.y)}" width="${u(sh.w)}" height="${u(sh.h)}"` +
...glyphRects(spec).map((r) => ` <rect x="${u(r.x)}" y="${u(r.y)}" width="${u(r.w)}" height="${u(r.h)}"/>`), ` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`
: ` <ellipse cx="${u(sh.cx)}" cy="${u(sh.cy)}" rx="${u(sh.rx)}" ry="${u(sh.ry)}"` +
` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`),
" </g>", " </g>",
` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`, ` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`,
` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`, ` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`,
@@ -403,10 +492,7 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
const SS = Math.max(1, Math.min(4, supersample)); const SS = Math.max(1, Math.min(4, supersample));
const big = size * SS; const big = size * SS;
const acc = Buffer.alloc(big * big * 4); const acc = Buffer.alloc(big * big * 4);
const circles = dots(spec); const shapes = iconShapes(spec);
const rects = glyphRects(spec);
const [i1, i2, i3] = spec.ink;
const [d1, d2, d3] = spec.dot;
const [g1, g2, g3] = spec.ring; const [g1, g2, g3] = spec.ring;
for (let py = 0; py < big; py += 1) { for (let py = 0; py < big; py += 1) {
@@ -420,16 +506,21 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
let r = spec.c1[0] + (spec.c2[0] - spec.c1[0]) * t; let r = spec.c1[0] + (spec.c2[0] - spec.c1[0]) * t;
let g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t; let g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t;
let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t; let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t;
// 紋:圓點 // 圖形清單:由後往前疊,與 SVG 的繪製順序相同
for (const d of circles) { for (const sh of shapes) {
const dx = u - d.cx; let hit;
const dy = v - d.cy; if (sh.type === "rect") {
if (dx * dx + dy * dy <= d.r * d.r) { hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h;
r += (d1 - r) * spec.dotAlpha; } else {
g += (d2 - g) * spec.dotAlpha; const dx = (u - sh.cx) / sh.rx;
b += (d3 - b) * spec.dotAlpha; const dy = (v - sh.cy) / sh.ry;
break; hit = dx * dx + dy * dy <= 1;
} }
if (!hit) continue;
const a = sh.alpha;
r += (sh.fill[0] - r) * a;
g += (sh.fill[1] - g) * a;
b += (sh.fill[2] - b) * a;
} }
// 邊框:與 SVG 的 stroke 完全相同的環帶 // 邊框:與 SVG 的 stroke 完全相同的環帶
//SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.0060.018 //SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.0060.018
@@ -438,15 +529,6 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
g += (g2 - g) * spec.ringAlpha; g += (g2 - g) * spec.ringAlpha;
b += (g3 - b) * spec.ringAlpha; b += (g3 - b) * spec.ringAlpha;
} }
// 字:實心方塊
for (const rc of rects) {
if (u >= rc.x && u < rc.x + rc.w && v >= rc.y && v < rc.y + rc.h) {
r = i1;
g = i2;
b = i3;
break;
}
}
acc[o] = Math.round(r); acc[o] = Math.round(r);
acc[o + 1] = Math.round(g); acc[o + 1] = Math.round(g);
acc[o + 2] = Math.round(b); acc[o + 2] = Math.round(b);
@@ -489,10 +571,33 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
// 產生並寫檔 // 產生並寫檔
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = null, source = null } = {}) { /**
const spec = iconSpec(slug, { code, palette }); * 產生圖示。有 `photo` 且工具齊全 → 用真實照片裁臉;否則畫向量人物形象(一樣有臉)。
const svg = renderSvg(spec, size); * 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。
const png = renderPng(spec, size); */
export function generateIcon(slug, {
size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null,
photo = null, pick = null, face = null,
} = {}) {
const spec = iconSpec(slug, { code, palette, style });
let svg;
let png;
let photoResult = null;
if (photo) {
const tmp = path.join(pl.personaDir(slug), ".sync", "portrait.tmp.png");
fs.mkdirSync(path.dirname(tmp), { recursive: true });
photoResult = renderPhotoPng(photo, tmp, size, { pick, face });
if (photoResult.ok) {
png = fs.readFileSync(tmp);
svg = photoSvg(spec, png, size);
spec.style = "photo";
}
fs.rmSync(tmp, { force: true });
}
if (!png) {
svg = renderSvg(spec, size);
png = renderPng(spec, size);
}
pl.writeText(iconSvgPath(slug), svg); pl.writeText(iconSvgPath(slug), svg);
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
fs.writeFileSync(iconPngPath(slug), png); fs.writeFileSync(iconPngPath(slug), png);
@@ -501,10 +606,15 @@ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette =
config.icon = { config.icon = {
...(config.icon || {}), ...(config.icon || {}),
letters: spec.letters, letters: spec.letters,
style: spec.style,
size, size,
generated_at: pl.nowIso(), generated_at: pl.nowIso(),
palette: spec.palette ? paletteToString(spec.palette) : null, palette: spec.palette ? paletteToString(spec.palette) : null,
source: source || spec.source || null, source: source || spec.source || null,
crop: photoResult?.ok
? { method: photoResult.info.method, face: photoResult.info.face,
box: photoResult.info.box, faces_found: photoResult.info.faces_found }
: null,
}; };
pl.writeJson(pl.configPath(slug), config); pl.writeJson(pl.configPath(slug), config);
return { return {
@@ -512,8 +622,184 @@ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette =
svg: iconSvgPath(slug), svg: iconSvgPath(slug),
png: iconPngPath(slug), png: iconPngPath(slug),
size, size,
photo: photoResult,
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
}; };
} }
export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug)); export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug));
// --------------------------------------------------------------------------- //
// 照片裁臉(選用的加值路徑)
// --------------------------------------------------------------------------- //
//
// 本體零依賴,但如果環境裡有 Pillow(+可選的 OpenCV 動漫臉偵測),
// 圖示就能直接用真實照片裁出的臉。缺工具時 `toolReport()` 會給出安裝指令。
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const PORTRAIT_PY = path.join(HERE, "portrait.py");
export const CASCADE_URL =
"https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml";
export function cascadePath() {
if (process.env.PERSONA_ANIME_CASCADE) return process.env.PERSONA_ANIME_CASCADE;
return path.join(os.homedir(), ".cache", "jsc-persona", "lbpcascade_animeface.xml");
}
/** 找一個能用的 python(優先吃 PERSONA_PYTHON,其次 venv,最後系統 python3)。 */
export function pythonPath() {
const candidates = [
process.env.PERSONA_PYTHON,
path.join(os.homedir(), ".cache", "jsc-persona", "venv", "bin", "python3"),
"python3",
].filter(Boolean);
for (const bin of candidates) {
const probe = spawnSync(bin, ["-c", "import sys;print(sys.version_info[0])"], { encoding: "utf8" });
if (probe.status === 0) return bin;
}
return null;
}
const pyHas = (bin, mod) =>
spawnSync(bin, ["-c", `import ${mod}`], { encoding: "utf8" }).status === 0;
/** 目前有哪些工具、缺什麼、怎麼補。 */
export function toolReport() {
const python = pythonPath();
const pillow = Boolean(python) && pyHas(python, "PIL");
const cv2 = Boolean(python) && pyHas(python, "cv2");
const cascade = fs.existsSync(cascadePath());
const venv = path.join(os.homedir(), ".cache", "jsc-persona", "venv");
const missing = [];
if (!python) {
missing.push({
what: "python3",
why: "解碼照片(JPEGWebPPNG)與裁切都靠它",
how: "sudo apt-get install -y python3 python3-venv",
});
}
if (!pillow) {
missing.push({
what: "Pillow",
why: "沒有它就無法把照片解碼成像素,也就無法裁臉",
how: `python3 -m venv ${venv} && ${venv}/bin/pip install -q pillow`,
});
}
if (!cv2) {
missing.push({
what: "OpenCVopencv-python-headless",
why: "自動找出臉的位置;沒有它就只能用「上方中央」的經驗法則裁切",
how: `${venv}/bin/pip install -q opencv-python-headless`,
});
}
if (!cascade) {
missing.push({
what: "動漫臉偵測模型 lbpcascade_animeface.xml",
why: "OpenCV 內建的模型認不出動漫臉,要這個才準",
how:
`mkdir -p ${path.dirname(cascadePath())} && curl -sL -o ${cascadePath()} ${CASCADE_URL}`,
});
}
return {
python,
pillow,
cv2,
cascade,
ready: Boolean(python && pillow),
faceDetection: Boolean(python && pillow && cv2),
animeFaceDetection: Boolean(python && pillow && cv2 && cascade),
missing,
venv,
};
}
/** 缺工具時要印給使用者看的提示(一行 what/why,一行指令)。 */
export function installHintLines(report) {
if (!report.missing.length) return [];
const lines = [" ⚠ 少了這些工具,暫時無法用真實照片裁臉(先用向量人物形象代替):"];
for (const m of report.missing) {
lines.push(`${m.what}${m.why}`);
lines.push(` ${m.how}`);
}
lines.push(" 裝好之後重跑 `icon generate --photo <圖片> --force` 就會換成照片裁臉版。");
return lines;
}
/** 取得參考照片:URL 就抓下來,本機路徑就直接用。 */
export async function fetchPhoto(source, destDir) {
if (/^https?:\/\//i.test(source)) {
const res = await fetch(source, { headers: { "User-Agent": "jsc-persona/icon" } });
if (!res.ok) throw new Error(`下載參考照片失敗(HTTP ${res.status}):${source}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(destDir, { recursive: true });
const ext = (source.split("?")[0].match(/\.(png|jpe?g|webp|gif)$/i)?.[1] || "img").toLowerCase();
const file = path.join(destDir, `reference.${ext}`);
fs.writeFileSync(file, buf);
return file;
}
const abs = path.resolve(source);
if (!fs.existsSync(abs)) throw new Error(`找不到參考照片:${abs}`);
return abs;
}
/** 列出參考照片裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */
export function listFaces(imagePath) {
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) };
try {
return { ...JSON.parse(String(proc.stdout).trim().split("\n").pop()), report };
} catch {
return { ok: false, report, reason: "portrait.py 的輸出不是 JSON" };
}
}
/** 用照片裁臉產生 icon.png(成功回傳結果,工具不足回 ok:false 與缺什麼)。 */
export function renderPhotoPng(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) {
const report = toolReport();
if (!report.ready) return { ok: false, report };
const args = [PORTRAIT_PY, "--input", imagePath, "--output", outPath, "--size", String(size),
"--radius", String(GEO.radius)];
if (report.cascade) args.push("--cascade", cascadePath());
if (face) args.push("--face", face);
else if (pick) args.push("--pick", pick);
const proc = spawnSync(report.python, args, { encoding: "utf8" });
if (proc.status !== 0) {
return { ok: false, report, reason: (proc.stderr || proc.stdout || "").trim().slice(0, 300) };
}
let info = null;
try {
info = JSON.parse(String(proc.stdout).trim().split("\n").pop());
} catch {
info = null;
}
if (!info?.ok) return { ok: false, report, reason: info?.reason || "portrait.py 沒有回報成功" };
return { ok: true, report, info };
}
/** 把裁好的 PNG 包成自成一體的 SVG(含圓角與瞳色外框)。 */
export function photoSvg(spec, pngBuffer, size = DEFAULT_SIZE) {
const S = size;
const u = (v) => Math.round(v * S * 1000) / 1000;
return [
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`,
` 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>`,
" <defs>",
` <clipPath id="badge"><rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}"/></clipPath>`,
" </defs>",
` <g clip-path="url(#badge)">`,
` <image x="0" y="0" width="${S}" height="${S}" preserveAspectRatio="xMidYMid slice"`,
` href="data:image/png;base64,${pngBuffer.toString("base64")}"/>`,
" </g>",
` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`,
` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`,
` fill="none" stroke="${hex(spec.ring)}" stroke-opacity="${spec.ringAlpha}" stroke-width="${u(0.012)}"/>`,
"</svg>",
"",
].join("\n");
}
+65 -9
View File
@@ -51,7 +51,7 @@ function emit(payload, asJson, lines) {
const FLAGS = new Set([ const FLAGS = new Set([
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all", "json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
"with-journal", "gzip", "record", "load", "allow-repeat", "with-journal", "gzip", "record", "load", "allow-repeat",
"if-due", "no-gitea", "public", "rename", "if-due", "no-gitea", "public", "rename", "from-source",
]); ]);
function parseArgs(argv) { function parseArgs(argv) {
@@ -1139,7 +1139,32 @@ commands.icon = async ({ flags, positional }) => {
]); ]);
return; return;
} }
if (action !== "generate") die(`未知 action${action}(可用 generate/show`); if (action === "faces") {
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 found = ic.listFaces(file);
if (!found.ok) {
emit(found, flags.json, [
`\u2716 \u7121\u6cd5\u5075\u6e2c\u81c9\uff1a${found.reason || "\u5de5\u5177\u4e0d\u8db3"}`,
...ic.installHintLines(found.report || ic.toolReport()),
]);
return;
}
emit(found, flags.json, [
`\u53c3\u8003\u7167\u7247 ${found.size[0]}\u00d7${found.size[1]}\uff5c\u5075\u6e2c\u65b9\u5f0f ${found.method || "\uff08\u7121\uff09"}\uff5c\u627e\u5230 ${found.faces.length} \u5f35\u81c9\uff1a`,
...found.faces.map((f) => ` #${f.index} ${f.w}\u00d7${f.h} @(${f.x},${f.y})\u3000\u4e2d\u5fc3 ${f.center.join(",")}`),
" \u591a\u89d2\u8272\u7684\u5716\u8acb\u5148\u770b\u904e\u539f\u5716\u518d\u6311\uff1a`icon generate --photo <\u5716> --pick <\u7d22\u5f15>`\uff08\u6216 `--face x,y,w,h`\uff09\u3002",
]);
return;
}
if (action !== "generate") die(`\u672a\u77e5 action\uff1a${action}\uff08\u53ef\u7528 generate/show/faces\uff09`);
requireOwner(slug, session); requireOwner(slug, session);
if (ic.hasIcon(slug) && !flags.force) { if (ic.hasIcon(slug) && !flags.force) {
die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`); die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`);
@@ -1166,17 +1191,42 @@ commands.icon = async ({ flags, positional }) => {
date: str(flags["source-date"]) || pl.nowIso().slice(0, 10), date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
} }
: null; : null;
const res = ic.generateIcon(slug, { size, palette, source }); const style = str(flags.style) || null;
if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}`);
// 參考照片:給了就試著裁臉;工具不足會退回向量人物形象並印出安裝指令
let photo = null;
if (flags.photo || (flags["from-source"] && source?.url)) {
const want = str(flags.photo) || source.url;
try {
photo = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
} catch (err) {
die(`${err.message}`);
}
}
const res = ic.generateIcon(slug, {
size, palette, source, style, photo,
pick: str(flags.pick) || null,
face: str(flags.face) || null,
});
const lines = [ const lines = [
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
` ${res.svg}${(res.bytes.svg / 1024).toFixed(1)} KB`, ` ${res.svg}${(res.bytes.svg / 1024).toFixed(1)} KB`,
` ${res.png}${(res.bytes.png / 1024).toFixed(1)} KB`, ` ${res.png}${(res.bytes.png / 1024).toFixed(1)} KB`,
res.spec.palette res.spec.style === "photo"
? ` 字母 ${res.spec.letters}|配色取自參考照片:${ic.paletteToString(res.spec.palette)}` ? ` 形象圖:真實照片裁臉(偵測方式 ${res.photo?.info?.method},裁切框 ${JSON.stringify(res.photo?.info?.box)}`
: ` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`, : res.spec.style === "portrait"
? ` 形象圖:向量人物(有臉)|配色取自參考照片:${ic.paletteToString(res.spec.palette)}`
: ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
...(source?.url || res.spec.source?.url ...(source?.url || res.spec.source?.url
? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`] ? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`]
: []), : []),
// 缺工具就把安裝方式講清楚,而不是默默降級
...(photo && res.photo && !res.photo.ok
? [
...(res.photo.reason ? [` ⚠ 照片裁臉失敗:${res.photo.reason}`] : []),
...ic.installHintLines(res.photo.report || ic.toolReport()),
]
: []),
]; ];
// 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像 // 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像
if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
@@ -1340,12 +1390,18 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
圖示(建立人格並補齊資料後跑): 圖示(建立人格並補齊資料後跑):
icon generate|show --session <id> [--size 512 --force --no-gitea] icon generate|show|faces --session <id> [--size 512 --force --no-gitea]
[--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."] [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."]
[--source-url <參考照片網址> --source-note <說明> --source-date <YYYY-MM-DD>] [--source-url <參考照片網址> --source-note <說明> --source-date <YYYY-MM-DD>]
[--photo <圖片路徑或網址> | --from-source] [--style portrait|badge]
[--pick largest|leftmost|rightmost|<索引> | --face x,y,w,h]
faces 會列出參考照片裡偵測到的臉——多角色的圖務必先看過再用 --pick 指定。
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
沒帶 --palette → 配色由編號/名字/emoji 雜湊而來。 沒帶 --palette → 徽章樣式,配色由編號/名字/emoji 雜湊而來。
帶了 --palette → 用「你實際看過的參考照片」萃取的顏色(必須同時帶 --source-url 存證)。 帶了 --palette → 向量人物形象(有臉),配色取自「你實際看過的參考照片」
(必須同時帶 --source-url 存證)。
帶了 --photo → 直接用那張照片裁出臉當形象圖;工具不足時會退回向量人物形象
並印出安裝指令(Pillow / OpenCV / 動漫臉模型)。
編號與 Gitea(存取庫名稱 = 人格編號): 編號與 Gitea(存取庫名稱 = 人格編號):
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public] code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""從參考照片裁出人物臉部,輸出成人格圖示用的正方形 PNG。
這支腳本是**選用的加值工具**: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] # 直接指定裁切框
多角色的圖片一定要挑臉:`--list` 看有哪些,再用 `--pick` 或 `--face` 指定。
輸出(stdout):一行 JSON。失敗時 ok=false,並附上 hint。
"""
import argparse
import json
import os
import sys
def emit(payload):
print(json.dumps(payload, ensure_ascii=False))
sys.exit(0)
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)。"""
try:
import cv2
except ImportError:
return [], None
candidates = []
if cascade_path and os.path.exists(cascade_path):
candidates.append((cascade_path, "anime-cascade"))
builtin = getattr(getattr(cv2, "data", None), "haarcascades", "")
if builtin:
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:
return [], None
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)
if clf.empty():
continue
except Exception:
continue
for sf, mn, ms in ladder:
try:
faces = clf.detectMultiScale(gray, scaleFactor=sf, minNeighbors=mn, minSize=(ms, ms))
except Exception:
continue
if len(faces):
return [tuple(int(v) for v in f) for f in faces], method
return [], None
def choose(faces, pick):
if not faces:
return None
if pick is None or pick == "largest":
return max(faces, key=lambda f: f[2] * f[3])
if pick == "leftmost":
return min(faces, key=lambda f: f[0])
if pick == "rightmost":
return max(faces, key=lambda f: f[0] + f[2])
if pick == "topmost":
return min(faces, key=lambda f: f[1])
try:
return sorted(faces, key=lambda f: f[0])[int(pick)]
except (ValueError, IndexError):
return None
def main():
ap = argparse.ArgumentParser()
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("--face", default=None, help="直接指定臉的框 x,y,w,h")
ap.add_argument("--list", action="store_true", help="只列出偵測到的臉,不輸出圖")
args = ap.parse_args()
try:
from PIL import Image, ImageDraw
except ImportError:
fail("缺少 Pillow", "pip install pillow")
try:
img = Image.open(args.input).convert("RGB")
except Exception as exc:
fail(f"讀不到圖片:{exc}", "確認檔案完整;WebP 需要較新的 Pillow")
W, H = img.size
faces, method = detect_faces(args.input, args.cascade)
if args.list:
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 只看偵測結果)")
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")
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 __name__ == "__main__":
main()
+49 -2
View File
@@ -541,7 +541,7 @@ check("圖示改用照片配色(瞳色當外框、亮色當紋路)",
palSpec.palette !== null && palSpec.ring.join() === "158,91,62" && palSpec.dot.join() === "242,235,227"); palSpec.palette !== null && palSpec.ring.join() === "158,91,62" && palSpec.dot.join() === "242,235,227");
check("來源網址與說明寫進 config(可查證)", (() => { check("來源網址與說明寫進 config(可查證)", (() => {
const icon = pl.loadConfig("GAMMA-01").icon || {}; const icon = pl.loadConfig("GAMMA-01").icon || {};
return icon.palette === PAL && icon.source?.url === SRC && return icon.palette.startsWith(PAL) && icon.source?.url === SRC &&
icon.source?.note === "測試用主視覺" && icon.source?.date === "2026-07-30"; icon.source?.note === "測試用主視覺" && icon.source?.date === "2026-07-30";
})(), JSON.stringify(pl.loadConfig("GAMMA-01").icon)); })(), JSON.stringify(pl.loadConfig("GAMMA-01").icon));
check("照片配色與雜湊配色畫出來不一樣", (() => { check("照片配色與雜湊配色畫出來不一樣", (() => {
@@ -553,7 +553,7 @@ check("照片配色與雜湊配色畫出來不一樣", (() => {
check("不帶 --palette 重畫會沿用已存的配色(不會變回雜湊色)", (() => { check("不帶 --palette 重畫會沿用已存的配色(不會變回雜湊色)", (() => {
cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64"]); cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64"]);
const again = ic.iconSpec("GAMMA-01"); const again = ic.iconSpec("GAMMA-01");
return again.palette !== null && ic.paletteToString(again.palette) === PAL; return again.palette !== null && ic.paletteToString(again.palette).startsWith(PAL);
})()); })());
check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (() => { check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (() => {
// 藍黑髮 + 淡粉洋裝:不收斂的話不論黑字白字都會有一端糊掉 // 藍黑髮 + 淡粉洋裝:不收斂的話不論黑字白字都會有一端糊掉
@@ -568,6 +568,53 @@ check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (
return Math.min(ratio(spec.ink, spec.c1), ratio(spec.ink, spec.c2)) >= 3; return Math.min(ratio(spec.ink, spec.c1), ratio(spec.ink, spec.c2)) >= 3;
})()); })());
console.log("⑯ 人物形象圖(有臉)與照片裁臉工具");
const facePal = ic.parsePalette("hair=#1b1b22,eye=#6b4a2f,accent=#f2b6cb,secondary=#4a7bc8,light=#fbeff3");
const faceSpec = ic.iconSpec("GAMMA-01", { palette: facePal, style: "portrait" });
check("有調色盤時預設畫「人物形象」而不是徽章",
ic.iconSpec("GAMMA-01", { palette: facePal }).style === "portrait" &&
ic.iconSpec("alpha").style !== "portrait", ic.iconSpec("alpha").style);
check("形象圖真的畫了五官(眼白/虹膜/瞳孔/嘴都在)", (() => {
const shapes = ic.iconShapes(faceSpec);
const eyeWhite = shapes.filter((sh) => sh.fill.join() === "252,252,255").length;
const iris = shapes.filter((sh) => sh.fill.join() === facePal.eye.join()).length;
const highlight = shapes.filter((sh) => sh.fill.join() === "255,255,255").length;
return shapes.length >= 20 && eyeWhite === 2 && iris === 2 && highlight === 2;
})(), `圖形數 ${ic.iconShapes(faceSpec).length}`);
check("形象圖用的是照片配色(髮色當底、瞳色當眼睛)",
faceSpec.c1.join() === facePal.hair.join() || faceSpec.c2.join() !== faceSpec.c1.join());
check("SVG 與 PNG 出自同一份圖形清單(SVG 有對應數量的 ellipse", (() => {
const svg = ic.renderSvg(faceSpec, 64);
const shapes = ic.iconShapes(faceSpec);
const ellipses = (svg.match(/<ellipse /g) || []).length;
const rects = (svg.match(/<rect /g) || []).length;
return ellipses === shapes.filter((sh) => sh.type === "ellipse").length &&
rects >= shapes.filter((sh) => sh.type === "rect").length;
})());
check("--style 可以強制畫回徽章",
ic.iconSpec("GAMMA-01", { palette: facePal, style: "badge" }).style === "badge");
const tools = ic.toolReport();
check("工具偵測會回報缺什麼與怎麼裝",
typeof tools.ready === "boolean" && Array.isArray(tools.missing) &&
tools.missing.every((m) => m.what && m.why && m.how));
check("缺工具時的提示含安裝指令", (() => {
const fake = { missing: [{ what: "Pillow", why: "解碼照片", how: "pip install pillow" }] };
const lines = ic.installHintLines(fake);
return lines.length >= 3 && lines.join("\n").includes("pip install pillow");
})());
check("工具齊全時提示為空", ic.installHintLines({ missing: [] }).length === 0);
check("`icon faces` 需要 --photo",
cli(["icon", "faces", "--session", S_CODE], { expectOk: false }).status !== 0);
check("形象圖同步到 Wiki 區(svg 與 png 都在)",
covered("icon.svg")[0] === "wiki" && covered("icon.png")[0] === "wiki");
check("Wiki 有專頁保存形象圖,並寫明來源", (() => {
const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
return page.includes("形象圖") && page.includes("icon.svg") && page.includes("icon.png") &&
page.includes("參考來源") && page.includes("https://example.invalid/key-visual.png");
})(), gt.wikiIconPage("GAMMA-01", "GAMMA-01").slice(0, 120));
check("Wiki 的保留檔不會被同步流程刪掉",
["Home.md", "Icon.md"].every((f) => gt.wikiHome && typeof gt.wikiIconPage === "function"));
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
process.exit(failed ? 1 : 0); process.exit(failed ? 1 : 0);
+53 -18
View File
@@ -1,12 +1,13 @@
--- ---
name: persona-icon name: persona-icon
description: 產生或更新人格的圖icon.svg + icon.png):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖後萃取髮色/瞳色/服裝色,再用這組配色繪製圖示,並記下來源網址備查;找不到參考圖時退回用編號雜湊配色。當使用者說要做人格頭像/圖/icon、要依角色最新造型更新圖示、說現在的圖示不像、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-createpersona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 description: 產生或更新人格的形象圖(icon.svg + icon.png,盡量看得到臉):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖,接著優先用臉部偵測從圖上裁出臉當頭像;缺少裁圖工具時改畫有五官的向量人物形象並提示如何安裝工具,完全沒有參考圖才退回編號徽章。來源網址與裁切框會存進 config 備查,圖示同時同步到 Gitea Wiki 與存取庫頭像。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說現在的圖示不像或看不到臉、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-createpersona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。
--- ---
# 🎨 persona-icon — 依最新造型產生人格圖示 # 🎨 persona-icon — 依最新造型產生人格形象圖(有臉)
**CLI**`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session <PERSONA_SESSION>` **CLI**`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session <PERSONA_SESSION>`
**時機****人格建立且 IDENTITYSOUL 補齊之後**——配色與字母綁在最終身分上。 **時機****人格建立且 IDENTITYSOUL 補齊之後**——形象綁在最終身分上。
**目標**:圖示要是**人物形象圖,盡量看得到臉**;退而求其次才是抽象徽章。
--- ---
@@ -57,7 +58,38 @@ description: 產生或更新人格的圖示(icon.svg + icon.png):先上網
| `secondary` | 服裝的第二色/配件色 | | `secondary` | 服裝的第二色/配件色 |
| `light` | 最亮的部位(白袖、皮膚亮面) | | `light` | 最亮的部位(白袖、皮膚亮面) |
### 4. 繪製 ### 4. 裁出臉(有工具的話,這是首選)
**圖示必須是人物形象,盡量看得到臉。** 環境裡如果有 Pillow(+OpenCV 動漫臉模型),
就直接從參考圖裁臉:
```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 generate --session <PERSONA_SESSION> --force \
--photo "<圖片網址或路徑>" --pick <索引> \
--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.." \
--source-url "<那張圖的網址>" --source-note "<作品(年份)+造型描述>"
```
**多角色的圖務必自己看過再挑**`icon faces` 只會告訴你座標,哪張臉是本人要由你判斷
(把裁出來的圖用 Read 打開確認一次最保險)。挑錯就是別人的臉。
工具不齊時 CLI 會**印出安裝指令**並自動退回第 5 步的向量形象——不會靜默失敗。
需要的工具:
| 工具 | 做什麼 | 怎麼裝(免 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. 沒有工具時:向量人物形象(一樣有臉)
```bash ```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \
@@ -70,37 +102,40 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \
- **`--palette` 一定要配 `--source-url`**:配色是從哪張圖來的必須留得下來(CLI 會強制)。 - **`--palette` 一定要配 `--source-url`**:配色是從哪張圖來的必須留得下來(CLI 會強制)。
- 兩者都會寫進 `state/config.json` 的 `icon` 欄位,之後 `icon show` 查得到。 - 兩者都會寫進 `state/config.json` 的 `icon` 欄位,之後 `icon show` 查得到。
- 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像同步到 Wiki 區 - 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像同步到 Wiki 區
並在 Wiki 產生一頁 **Icon**(同時展示 SVG 與 PNG,附上來源網址與裁切框)。
找不到可靠的參考圖 → **不要硬掰**,直接 `icon generate` 不帶 `--palette` 找不到可靠的參考圖 → **不要硬掰**,直接 `icon generate` 不帶 `--palette`
用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。 用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。
### 5. 回報 ### 6. 回報
一句話說明:用了哪部作品哪一年的視覺、取到什麼配色、圖示放哪裡。 一句話說明:用了哪部作品哪一年的視覺、取到什麼配色、圖示放哪裡。
把來源網址附上,讓使用者可以自己核對。 把來源網址附上,讓使用者可以自己核對。
## 圖示長什麼樣 ## 三種樣式
512×512 圓角徽章:**髮色 → 服裝主色**的對角漸層、**瞳色**外框、**亮色**點陣紋、 | 樣式 | 什麼時候用 | 長什麼樣 |
中央是編號前兩個字母(`ASUNA-01` → `AS`)。 | --- | --- | --- |
| `photo` | 有參考圖 **且**工具齊全(最佳) | 從官方視覺**裁出的臉**,圓角+瞳色外框 |
| `portrait` | 有參考圖但沒工具 | 向量人物形象:髮型、瞳色、服裝色都取自照片,**有五官** |
| `badge` | 完全沒有參考圖(原創人格) | 雙色漸層徽章 + 編號前兩個字母 |
- 兩個顏色一深一淺時(例:藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓, - `portrait` 與 `badge` 由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**,兩邊必然一致;
PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。
- `photo` 的 SVG 會把裁好的 PNG 以 base64 內嵌,一樣自成一體(不外連)。
- `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓,
確保字讀得到——顏色仍然是照片來的,只是收斂色階。 確保字讀得到——顏色仍然是照片來的,只是收斂色階。
- SVG 與 PNG 是**同一張圖**;PNG 由自寫的柵格器產生,不依賴任何影像函式庫。
## 為什麼不是把照片本身當圖示 ## 界線
1. **技術上**:環境裡沒有任何影像解碼器(rsvgimagemagickPillow 都沒有) 裁出來的是**角色臉部的縮圖**,用途是人格身分辨識(頭像)。原始美術作品的權利屬於原作者
JPEG/WebP 讀不進來,無法把原圖轉成 PNG 存取庫預設私有;不要把它當成可以散布的素材,也不要重製整張原圖
2. **權利上**:把他人的美術作品原樣放進存取庫是散布,不是引用。
取配色是有依據、又不搬運原圖的做法。使用者若堅持要用原圖當頭像,
請他自己把圖片上傳到 Gitea 的存取庫設定頁。
## 邊界 ## 邊界
- 只能對「本 session 已載入的人格」產生圖示。 - 只能對「本 session 已載入的人格」產生圖示。
- 已有圖示時要重畫必須加 `--force`。 - 已有圖示時要重畫必須加 `--force`。
- 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。 - 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。
- `.sync/reference.*` 是下載回來的參考圖快取,可以安全刪除。
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。