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>
177 lines
6.0 KiB
Python
177 lines
6.0 KiB
Python
#!/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()
|