diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bca8d6e..8e3c725 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.5", + "version": "0.0.6", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0496b79..12534aa 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.5", + "version": "0.0.6", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 5bf748f..c99307d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,10 +34,15 @@ **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 -12. **圖示配色優先取自「最新登場」的官方視覺**(`/jsc-persona:persona-icon`): - 上網找圖 → **用 Read 親眼看過** → 取色 → `--palette` 搭配 `--source-url`(CLI 強制存證)。 - **沒看過圖就不准填顏色**;找不到官方圖就退回編號雜湊配色並告知使用者。 - 不把原圖放進存取庫:那是散布他人美術作品,而且環境裡也沒有解碼器可用。 +12. **圖示必須是人物形象圖(盡量有臉)**,來源是該人格「最新一次登場」的官方視覺 + (`/jsc-persona:persona-icon`):上網找圖 → **用 Read 親眼看過** → 有 Pillow+OpenCV 就 + `--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** 頁 + 展示兩種格式並列出來源、樣式與裁切框。 ## 慣例 diff --git a/README.md b/README.md index 16cf941..cf779e6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 --- -## 十二條硬規則 +## 十三條硬規則 | 規則 | 怎麼做到 | | --- | --- | @@ -26,7 +26,8 @@ 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. 建立並補齊資料後產生圖示,配色取自最新造型** | `/jsc-persona:persona-icon` 先上網找出該人格**最新一次登場**的官方視覺、**親眼看過**那張圖,取出髮色/瞳色/服裝色,再繪製 `icon.svg` + `icon.png`(512×512),來源網址一併存進 `config.json` 備查;沒有參考圖才退回用編號雜湊配色。PNG 是**自寫柵格器 + zlib 手工編碼**,不依賴任何影像函式庫 | +| **12. 圖示是人物形象圖,取自最新登場的官方視覺** | `/jsc-persona:persona-icon` 上網找出該人格**最新一次登場**的官方視覺、**親眼看過**,有 Pillow+OpenCV 就直接**裁出臉**當形象圖;沒工具則畫**有五官的向量人物**(配色同樣取自那張圖),並印出安裝指令。來源網址與裁切框存進 `config.json` 備查 | +| **13. Wiki 保存形象圖** | `icon.svg` 與 `icon.png` 一起同步到 Wiki 區,另外自動產生一頁 **Icon** 同時展示兩種格式並列出來源、樣式與裁切框;PNG 同時設為存取庫頭像 | --- @@ -146,11 +147,27 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 ## 人格圖示(SVG + PNG,零外部依賴) -建立人格**並補齊 IDENTITY/SOUL 之後**產生,一張 512×512 的幾何徽章。配色有兩種來源: +建立人格**並補齊 IDENTITY/SOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**, +依環境有無工具分三種樣式: -**① 取自最新造型(`/jsc-persona:persona-icon`,動漫角色建議用這個)** +| 樣式 | 條件 | 長什麼樣 | +| --- | --- | --- | +| **`photo`** | 有參考圖 **且** 有 Pillow+OpenCV | 從官方視覺**裁出的臉**(自動臉部偵測),圓角+瞳色外框 | +| **`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, 確保字讀得到——顏色仍然是照片來的,只是收斂色階。 -**② 沒有參考圖時:編號雜湊** - -| 元素 | 怎麼決定 | -| --- | --- | -| 雙色對角漸層 | `sha256(編號|Name|Emoji)` → 色相/飽和度/明度 | -| 5×5 左右對稱點陣紋 | 同一組雜湊的位元 | -| 中央兩個字母 | 編號的前兩個字,自動選黑或白確保對比 | +沒有參考圖時(原創人格)才退回雜湊配色:`sha256(編號|Name|Emoji)` 決定漸層與點陣紋。 - **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01` 與 `ASUNA-02` 明顯不同。 - SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。 @@ -180,17 +191,20 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 (rsvg/imagemagick/Pillow/ffmpeg 都沒有),JPEG/WebP 讀不進來,無法把原圖轉成 PNG; 而且把他人的美術作品原樣放進存取庫是散布、不是引用。**取配色**是有依據又不搬運原圖的做法。 emoji 同理無法柵格化,但仍參與雜湊配色。 -- 圖示屬於低頻資料 → 同步到 **Wiki 區**,並自動設成 Gitea **存取庫頭像**。 +- 圖示屬於低頻資料 → `icon.svg` 與 `icon.png` 都同步到 **Wiki 區**,並自動產生一頁 **Icon** + 展示兩種格式與來源;PNG 同時設成 Gitea **存取庫頭像**。 ```bash -# 取自最新造型(必須同時給來源網址) +# 先看參考圖裡有幾張臉(多角色的主視覺務必先看) +node scripts/persona.mjs icon faces --session --photo "<圖片網址>" + +# 裁臉 + 取色(--palette 必須配 --source-url) node scripts/persona.mjs icon generate --session --force \ + --photo "<圖片網址>" --pick 1 \ --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ --source-url "<官方視覺的網址>" --source-note "<作品(年份)+造型描述>" -# 沒有參考圖:編號雜湊配色 -node scripts/persona.mjs icon generate --session [--size 512] [--force] -node scripts/persona.mjs icon show --session # 看配色與來源 +node scripts/persona.mjs icon show --session # 看樣式、配色、來源與裁切框 ``` ## 人格編號與 Gitea 儲存 @@ -355,11 +369,11 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 136 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示與照片配色、hooks +node scripts/selftest.mjs # 148 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、形象圖與裁臉、hooks ``` 檔案結構:`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`(自我測試)。 --- diff --git a/plugin.json b/plugin.json index 143a0d4..f4b76bc 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.5", + "version": "0.0.6", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index e2d292b..0f9d6aa 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -306,7 +306,7 @@ function listTrackedFiles(dir) { } 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" }; /** @@ -389,6 +389,58 @@ function unstageArea(slug, area, dir, only = null) { 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 ? 'icon.svg' : "(缺)"} ` + + `| ${hasPng ? 'icon.png' : "(缺)"} |`, + `| [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 上點進去就看得懂這是誰。 */ export function wikiHome(slug, code) { const ident = pl.identityFields(slug); @@ -417,6 +469,7 @@ export function wikiHome(slug, code) { "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)", "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity", "- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解", + "- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源", "- [Memory INDEX](Memory-INDEX) — 長期記憶索引", "", "### 長期記憶", @@ -466,7 +519,10 @@ export async function pushArea(slug, area, { message = "", code = null, owner = const theOwner = owner || (await resolveOwner()); const { host } = giteaEnv(); 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); gitOrThrow(["add", "-A"], dir, "git add"); 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) { git(["reset", "--hard", "--quiet", `origin/${branch}`], 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); if (!git(["diff", "--cached", "--quiet"], dir).ok) { git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir); diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index 1b97ae7..73fcd8f 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -15,9 +15,12 @@ // 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。 import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import zlib from "node:zlib"; import crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import * as pl from "./persona-lib.mjs"; export const ICON_SVG = "icon.svg"; @@ -148,7 +151,7 @@ function harmonize(c1, c2, target = 3.2) { // 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。 // 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 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) { 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.secondary ??= out.accent; out.light ??= [246, 240, 236]; + out.skin ??= mix(out.light, [242, 201, 168], 0.65); // 沒指定就用偏暖的膚色 return out; } export const paletteToString = (p) => 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 config = pl.loadConfig(slug); const theCode = code || config.code || slug; @@ -238,6 +244,8 @@ export function iconSpec(slug, { code = null, identity = null, palette = null } dot: dot || ink, ringAlpha, dotAlpha, + // 有調色盤(=看過參考照片)就畫人物形象;沒有的話只能畫徽章 + style: style || config.icon?.style || (pal ? "portrait" : "badge"), palette: pal, source: pal ? config.icon?.source || null : null, pattern, @@ -273,6 +281,82 @@ function dots(spec) { 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) { const cell = GEO.glyphCell; @@ -309,13 +393,18 @@ export function renderSvg(spec, size = DEFAULT_SIZE) { ` `, ` `, " ", + ` `, + ` `, + " ", " ", ` `, - ` `, - ...dots(spec).map((d) => ` `), - " ", - ` `, - ...glyphRects(spec).map((r) => ` `), + ` `, + ...iconShapes(spec).map((sh) => + sh.type === "rect" + ? ` ` + : ` `), " ", ` = sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h; + } else { + const dx = (u - sh.cx) / sh.rx; + const dy = (v - sh.cy) / sh.ry; + 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 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.006~0.018) @@ -438,15 +529,6 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { g += (g2 - g) * 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 + 1] = Math.round(g); 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 }); - const svg = renderSvg(spec, size); - const png = renderPng(spec, size); +/** + * 產生圖示。有 `photo` 且工具齊全 → 用真實照片裁臉;否則畫向量人物形象(一樣有臉)。 + * 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。 + */ +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); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); fs.writeFileSync(iconPngPath(slug), png); @@ -501,10 +606,15 @@ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = config.icon = { ...(config.icon || {}), letters: spec.letters, + style: spec.style, size, generated_at: pl.nowIso(), palette: spec.palette ? paletteToString(spec.palette) : 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); return { @@ -512,8 +622,184 @@ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = svg: iconSvgPath(slug), png: iconPngPath(slug), size, + photo: photoResult, bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, }; } 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: "解碼照片(JPEG/WebP/PNG)與裁切都靠它", + 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: "OpenCV(opencv-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 [ + ``, + ` ${spec.code}${spec.name ? ` ${spec.name}` : ""}`, + " ", + ` `, + " ", + ` `, + ` `, + " ", + ` `, + "", + "", + ].join("\n"); +} diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 8c7740c..46a87dc 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -51,7 +51,7 @@ function emit(payload, asJson, lines) { const FLAGS = new Set([ "json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all", "with-journal", "gzip", "record", "load", "allow-repeat", - "if-due", "no-gitea", "public", "rename", + "if-due", "no-gitea", "public", "rename", "from-source", ]); function parseArgs(argv) { @@ -1139,7 +1139,32 @@ commands.icon = async ({ flags, positional }) => { ]); 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); if (ic.hasIcon(slug) && !flags.force) { die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`); @@ -1166,17 +1191,42 @@ commands.icon = async ({ flags, positional }) => { date: str(flags["source-date"]) || pl.nowIso().slice(0, 10), } : 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 = [ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, - res.spec.palette - ? ` 字母 ${res.spec.letters}|配色取自參考照片:${ic.paletteToString(res.spec.palette)}` - : ` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`, + res.spec.style === "photo" + ? ` 形象圖:真實照片裁臉(偵測方式 ${res.photo?.info?.method},裁切框 ${JSON.stringify(res.photo?.info?.box)})` + : res.spec.style === "portrait" + ? ` 形象圖:向量人物(有臉)|配色取自參考照片:${ic.paletteToString(res.spec.palette)}` + : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, ...(source?.url || res.spec.source?.url ? [` 參考來源:${(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 存取庫的頭像 if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { @@ -1340,12 +1390,18 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) 圖示(建立人格並補齊資料後跑): - icon generate|show --session [--size 512 --force --no-gitea] + icon generate|show|faces --session [--size 512 --force --no-gitea] [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."] [--source-url <參考照片網址> --source-note <說明> --source-date ] + [--photo <圖片路徑或網址> | --from-source] [--style portrait|badge] + [--pick largest|leftmost|rightmost|<索引> | --face x,y,w,h] + faces 會列出參考照片裡偵測到的臉——多角色的圖務必先看過再用 --pick 指定。 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 - 沒帶 --palette → 配色由編號/名字/emoji 雜湊而來。 - 帶了 --palette → 用「你實際看過的參考照片」萃取的顏色(必須同時帶 --source-url 存證)。 + 沒帶 --palette → 徽章樣式,配色由編號/名字/emoji 雜湊而來。 + 帶了 --palette → 向量人物形象(有臉),配色取自「你實際看過的參考照片」 + (必須同時帶 --source-url 存證)。 + 帶了 --photo → 直接用那張照片裁出臉當形象圖;工具不足時會退回向量人物形象 + 並印出安裝指令(Pillow / OpenCV / 動漫臉模型)。 編號與 Gitea(存取庫名稱 = 人格編號): code show|assign|next --session [--romaji <英文名> --code --rename --force --public] diff --git a/scripts/portrait.py b/scripts/portrait.py new file mode 100644 index 0000000..1abb910 --- /dev/null +++ b/scripts/portrait.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""從參考照片裁出人物臉部,輸出成人格圖示用的正方形 PNG。 + +這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它照樣能產生 +向量人物形象。裝了 Pillow(+可選的 OpenCV 動漫臉偵測)之後,圖示就能改用真實照片裁臉。 + +用法: + python3 portrait.py --input <圖片> --list # 列出偵測到的所有臉 + python3 portrait.py --input <圖片> --output [--size 512] + [--cascade ] [--pick largest|leftmost|rightmost|] + [--face x,y,w,h] # 直接指定裁切框 + +多角色的圖片一定要挑臉:`--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() diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 32ee996..2990a46 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -541,7 +541,7 @@ check("圖示改用照片配色(瞳色當外框、亮色當紋路)", palSpec.palette !== null && palSpec.ring.join() === "158,91,62" && palSpec.dot.join() === "242,235,227"); check("來源網址與說明寫進 config(可查證)", (() => { 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"; })(), JSON.stringify(pl.loadConfig("GAMMA-01").icon)); check("照片配色與雜湊配色畫出來不一樣", (() => { @@ -553,7 +553,7 @@ check("照片配色與雜湊配色畫出來不一樣", (() => { check("不帶 --palette 重畫會沿用已存的配色(不會變回雜湊色)", (() => { cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64"]); 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("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (() => { // 藍黑髮 + 淡粉洋裝:不收斂的話不論黑字白字都會有一端糊掉 @@ -568,6 +568,53 @@ check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", ( 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(/ 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(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-icon/SKILL.md b/skills/persona-icon/SKILL.md index 2990713..6df60b9 100644 --- a/skills/persona-icon/SKILL.md +++ b/skills/persona-icon/SKILL.md @@ -1,12 +1,13 @@ --- name: persona-icon -description: 產生或更新人格的圖示(icon.svg + icon.png):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖後萃取髮色/瞳色/服裝色,再用這組配色繪製圖示,並記下來源網址備查;找不到參考圖時退回用編號雜湊配色。當使用者說要做人格頭像/圖示/icon、要依角色最新造型更新圖示、說現在的圖示不像、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 +description: 產生或更新人格的形象圖(icon.svg + icon.png,盡量看得到臉):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖,接著優先用臉部偵測從圖上裁出臉當頭像;缺少裁圖工具時改畫有五官的向量人物形象並提示如何安裝工具,完全沒有參考圖才退回編號徽章。來源網址與裁切框會存進 config 備查,圖示同時同步到 Gitea Wiki 與存取庫頭像。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說現在的圖示不像或看不到臉、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 --- -# 🎨 persona-icon — 依最新造型產生人格圖示 +# 🎨 persona-icon — 依最新造型產生人格形象圖(有臉) **CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) -**時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——配色與字母綁在最終身分上。 +**時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——形象綁在最終身分上。 +**目標**:圖示要是**人物形象圖,盡量看得到臉**;退而求其次才是抽象徽章。 --- @@ -57,7 +58,38 @@ description: 產生或更新人格的圖示(icon.svg + icon.png):先上網 | `secondary` | 服裝的第二色/配件色 | | `light` | 最亮的部位(白袖、皮膚亮面) | -### 4. 繪製 +### 4. 裁出臉(有工具的話,這是首選) + +**圖示必須是人物形象,盡量看得到臉。** 環境裡如果有 Pillow(+OpenCV 動漫臉模型), +就直接從參考圖裁臉: + +```bash +# 先看看圖裡有幾張臉——多角色的主視覺一定要先看 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon faces --session --photo "<圖片網址或路徑>" + +# 挑一張(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --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 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 會強制)。 - 兩者都會寫進 `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`, 用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。 -### 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. **技術上**:環境裡沒有任何影像解碼器(rsvg/imagemagick/Pillow 都沒有), - JPEG/WebP 讀不進來,無法把原圖轉成 PNG。 -2. **權利上**:把他人的美術作品原樣放進存取庫是散布,不是引用。 - -取配色是有依據、又不搬運原圖的做法。使用者若堅持要用原圖當頭像, -請他自己把圖片上傳到 Gitea 的存取庫設定頁。 +裁出來的是**角色臉部的縮圖**,用途是人格身分辨識(頭像)。原始美術作品的權利屬於原作者, +存取庫預設私有;不要把它當成可以散布的素材,也不要重製整張原圖。 ## 邊界 - 只能對「本 session 已載入的人格」產生圖示。 - 已有圖示時要重畫必須加 `--force`。 - 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。 +- `.sync/reference.*` 是下載回來的參考圖快取,可以安全刪除。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。