feat: 從找圖開始優化——高解析度官方設定稿 → 去背 → 合成為形象圖
1. 找圖(icon search)
從 Fandom API 撈角色頁的所有圖片,依「解析度 + 是不是官方設定稿」排序。
官方設定稿(Full Body / Character Design / Avatar)是最好的來源:
* 773×1056 起跳,遠勝角色資料庫的 230px 縮圖
* **多半本來就是透明底 PNG**,去背幾乎免費、邊緣完美
新增 icon measure:回報解析度、臉佔比、背景是透明/單色/有場景、去背難度。
2. 去背(icon cutout)→ icon/portrait-cutout.png
三條路徑自動選:
source-alpha 原圖已是透明底(官方設定稿常見)→ 完美
plain-background 純白/單色底,色距去背 + 最大連通區 + 補洞 → 很好
grabcut 有場景時用臉的位置當前景種子 → 普通
實測記錄:把臉從 2026 主視覺裁下來再 GrabCut,結衣的黑髮會被整片當成背景切掉;
換成官方設定稿之後這問題直接消失——所以「找對圖」比「去背演算法」更關鍵。
3. 合成(icon generate --from-cutout)
自動裁成頭肩構圖再疊到角色配色的漸層底上。官方設定稿常是正反兩面並排,
不裁會變成兩個人,所以 compose 預設 --crop head(--zoom 可調鬆緊)。
產出 icon.svg(內嵌同一張 PNG,自成一體不外連)、icon.png,
以及 icon/portrait.svg 與 512/1024 兩個解析度。
向量重繪(--features)保留為「找不到可用官方圖」時的退路。
已更新兩個真實人格(皆為 cutout 樣式,Wiki 同步已驗證):
ASUNA-01 Asuna's SAO Avatar Full Body(773×1056,透明底)— 對應 2026
《Unanswered//butterfly》重述的早期艾恩葛朗特
YUI-01 Yui's ALO Pixie Form Full Body(773×1056,透明底)— 現行 Unital Ring
章的導航妖精形態
順手修掉兩個 bug:
* 合成路徑下 svg 為 null,回傳時 Buffer.byteLength(null) 直接崩潰。
* verifyArea 解析 git status --porcelain 時,因為 git() 會 trim 輸出,
開頭空白已消失(" M x" → "M x"),正規式對不上,檔名前面多一個 M。
selftest 175 項全綠(新增第 ⑲ 節:找圖評分、去背三路徑、compose 預設裁頭肩、
去背圖在 icon/ 會同步、SVG 不外連、沒有去背圖時 --from-cutout 會擋下)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+265
-72
@@ -1,17 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""從參考照片裁出人物臉部,輸出成人格圖示用的正方形 PNG。
|
||||
"""人格形象圖的影像處理:量測、找臉、裁大頭照、去背、合成成圖示。
|
||||
|
||||
這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它照樣能產生
|
||||
向量人物形象。裝了 Pillow(+可選的 OpenCV 動漫臉偵測)之後,圖示就能改用真實照片裁臉。
|
||||
這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它也能畫出
|
||||
向量人物形象;裝了 Pillow(+可選的 OpenCV 臉部偵測)之後,就能改用官方圖去背當形象圖。
|
||||
|
||||
用法:
|
||||
python3 portrait.py --input <圖片> --list # 列出偵測到的所有臉
|
||||
python3 portrait.py --input <圖片> --output <out.png> [--size 512]
|
||||
[--cascade <xml>] [--pick largest|leftmost|rightmost|<index>]
|
||||
[--face x,y,w,h] # 直接指定裁切框
|
||||
模式:
|
||||
--mode measure 量測:尺寸、臉的位置、背景是不是單色(決定去背好不好做)
|
||||
--mode faces 列出偵測到的所有臉
|
||||
--mode headshot 裁出大頭照(給人看的底稿)
|
||||
--mode cutout 去背,輸出透明 PNG
|
||||
--mode compose 把去背圖合成到圓角漸層底上,產出最終圖示
|
||||
|
||||
多角色的圖片一定要挑臉:`--list` 看有哪些,再用 `--pick` 或 `--face` 指定。
|
||||
輸出(stdout):一行 JSON。失敗時 ok=false,並附上 hint。
|
||||
輸出(stdout):一行 JSON。失敗時 ok=false 並附上 hint。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -29,13 +29,19 @@ def fail(reason, hint=None):
|
||||
emit({"ok": False, "reason": reason, "hint": hint})
|
||||
|
||||
|
||||
def detect_faces(path, cascade_path):
|
||||
"""回傳 (faces, method);faces 是 [(x, y, w, h), ...],偵測不到就回 ([], None)。"""
|
||||
def load_cv2():
|
||||
try:
|
||||
import cv2
|
||||
return cv2
|
||||
except ImportError:
|
||||
return [], None
|
||||
return None
|
||||
|
||||
|
||||
def detect_faces(path, cascade_path):
|
||||
"""回傳 (faces, method)。faces = [(x,y,w,h), ...]。"""
|
||||
cv2 = load_cv2()
|
||||
if cv2 is None:
|
||||
return [], None
|
||||
candidates = []
|
||||
if cascade_path and os.path.exists(cascade_path):
|
||||
candidates.append((cascade_path, "anime-cascade"))
|
||||
@@ -44,7 +50,6 @@ def detect_faces(path, cascade_path):
|
||||
frontal = os.path.join(builtin, "haarcascade_frontalface_default.xml")
|
||||
if os.path.exists(frontal):
|
||||
candidates.append((frontal, "frontal-cascade"))
|
||||
|
||||
try:
|
||||
image = cv2.imread(path)
|
||||
if image is None:
|
||||
@@ -52,9 +57,6 @@ def detect_faces(path, cascade_path):
|
||||
gray = cv2.equalizeHist(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY))
|
||||
except Exception:
|
||||
return [], None
|
||||
|
||||
# 由嚴到寬試幾組參數:先求準,找不到才放寬
|
||||
ladder = [(1.05, 5, 40), (1.05, 3, 32), (1.02, 2, 24)]
|
||||
for xml, method in candidates:
|
||||
try:
|
||||
clf = cv2.CascadeClassifier(xml)
|
||||
@@ -62,7 +64,7 @@ def detect_faces(path, cascade_path):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
for sf, mn, ms in ladder:
|
||||
for sf, mn, ms in [(1.05, 5, 40), (1.05, 3, 32), (1.02, 2, 24)]:
|
||||
try:
|
||||
faces = clf.detectMultiScale(gray, scaleFactor=sf, minNeighbors=mn, minSize=(ms, ms))
|
||||
except Exception:
|
||||
@@ -89,87 +91,278 @@ def choose(faces, pick):
|
||||
return None
|
||||
|
||||
|
||||
def background_report(img):
|
||||
"""看四個角與外框一圈:底色一不一致、是不是淺色。單色底=去背可以做得很乾淨。"""
|
||||
w, h = img.size
|
||||
px = img.convert("RGB").load()
|
||||
samples = []
|
||||
step = max(1, min(w, h) // 60)
|
||||
for x in range(0, w, step):
|
||||
samples.append(px[x, 0])
|
||||
samples.append(px[x, h - 1])
|
||||
for y in range(0, h, step):
|
||||
samples.append(px[0, y])
|
||||
samples.append(px[w - 1, y])
|
||||
avg = tuple(sum(c[i] for c in samples) / len(samples) for i in range(3))
|
||||
var = sum(max(abs(c[i] - avg[i]) for i in range(3)) for c in samples) / len(samples)
|
||||
return {
|
||||
"color": [round(v) for v in avg],
|
||||
"spread": round(var, 1), # 越小越單色
|
||||
"plain": bool(var < 18), # 單色底
|
||||
"light": bool(sum(avg) / 3 > 200),
|
||||
}
|
||||
|
||||
|
||||
def alpha_from_plain_bg(img, bg_color, tol=34, feather=1.2):
|
||||
"""單色底去背:離底色越近越透明,再對邊緣做一點羽化。"""
|
||||
from PIL import Image, ImageFilter
|
||||
import math
|
||||
rgb = img.convert("RGB")
|
||||
w, h = rgb.size
|
||||
px = rgb.load()
|
||||
mask = Image.new("L", (w, h), 255)
|
||||
mp = mask.load()
|
||||
br, bg_, bb = bg_color
|
||||
hard = tol * tol
|
||||
soft = (tol * 2.1) ** 2
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b = px[x, y]
|
||||
d = (r - br) ** 2 + (g - bg_) ** 2 + (b - bb) ** 2
|
||||
if d <= hard:
|
||||
mp[x, y] = 0
|
||||
elif d < soft:
|
||||
mp[x, y] = int(255 * (math.sqrt(d) - tol) / (tol * 1.1))
|
||||
# 只留最大的一塊,避免把角色身上和底色相近的區塊也挖掉
|
||||
cv2 = load_cv2()
|
||||
if cv2 is not None:
|
||||
import numpy as np
|
||||
m = np.array(mask)
|
||||
binary = (m > 96).astype("uint8")
|
||||
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||
n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
|
||||
if n > 1:
|
||||
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
|
||||
keep = (labels == largest)
|
||||
# 洞(例如手臂圍出的空隙)補回來
|
||||
filled = cv2.morphologyEx(keep.astype("uint8"), cv2.MORPH_CLOSE, np.ones((15, 15), np.uint8))
|
||||
m = np.where(filled > 0, m, 0)
|
||||
mask = Image.fromarray(m)
|
||||
return mask.filter(ImageFilter.GaussianBlur(feather))
|
||||
|
||||
|
||||
def alpha_from_grabcut(path, face, iters=8):
|
||||
"""有背景的圖:用臉的位置當前景種子跑 GrabCut。"""
|
||||
cv2 = load_cv2()
|
||||
if cv2 is None:
|
||||
return None
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
img = cv2.imread(path)
|
||||
if img is None:
|
||||
return None
|
||||
h, w = img.shape[:2]
|
||||
mask = np.full((h, w), cv2.GC_PR_BGD, np.uint8)
|
||||
mask[int(h * 0.03):int(h * 0.99), int(w * 0.05):int(w * 0.95)] = cv2.GC_PR_FGD
|
||||
if face is not None:
|
||||
fx, fy, fw, fh = face
|
||||
cx, cy = fx + fw // 2, fy + fh // 2
|
||||
cv2.ellipse(mask, (cx, cy), (int(fw * 0.42), int(fh * 0.48)), 0, 0, 360, cv2.GC_FGD, -1)
|
||||
cv2.ellipse(mask, (cx, int(cy - fh * 0.28)), (int(fw * 0.80), int(fh * 0.70)), 0, 0, 360,
|
||||
cv2.GC_FGD, -1)
|
||||
cv2.rectangle(mask, (int(cx - fw * 0.85), int(cy + fh * 0.8)), (int(cx + fw * 0.85), h - 1),
|
||||
cv2.GC_FGD, -1)
|
||||
b = max(2, int(min(w, h) * 0.015))
|
||||
mask[:b, :] = cv2.GC_BGD
|
||||
mask[-b:, :] = cv2.GC_BGD
|
||||
mask[:, :b] = cv2.GC_BGD
|
||||
mask[:, -b:] = cv2.GC_BGD
|
||||
bgd, fgd = np.zeros((1, 65), np.float64), np.zeros((1, 65), np.float64)
|
||||
try:
|
||||
cv2.grabCut(img, mask, None, bgd, fgd, iters, cv2.GC_INIT_WITH_MASK)
|
||||
except Exception:
|
||||
return None
|
||||
m = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD), 255, 0).astype("uint8")
|
||||
n, labels, stats, _ = cv2.connectedComponentsWithStats((m > 0).astype("uint8"), 8)
|
||||
if n > 1:
|
||||
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
|
||||
m = np.where(labels == largest, 255, 0).astype("uint8")
|
||||
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
|
||||
m = cv2.GaussianBlur(m, (5, 5), 0)
|
||||
return Image.fromarray(m)
|
||||
|
||||
|
||||
def head_box(img_size, face, zoom):
|
||||
"""由臉的框推出「頭肩構圖」的正方形裁切框。"""
|
||||
W, H = img_size
|
||||
if face is None:
|
||||
side = min(W, H)
|
||||
cx, cy = W / 2, min(H / 2, side * 0.42)
|
||||
else:
|
||||
x, y, w, h = face
|
||||
cx, cy = x + w / 2, y + h / 2 - h * 0.06
|
||||
side = min(max(w, h) * zoom, min(W, H))
|
||||
left = int(max(0, min(W - side, cx - side / 2)))
|
||||
top = int(max(0, min(H - side, cy - side / 2)))
|
||||
return (left, top, int(left + side), int(top + side))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--mode", default="headshot",
|
||||
choices=["measure", "faces", "headshot", "cutout", "compose"])
|
||||
ap.add_argument("--input", required=True)
|
||||
ap.add_argument("--output")
|
||||
ap.add_argument("--size", type=int, default=512)
|
||||
ap.add_argument("--cascade", default=None)
|
||||
ap.add_argument("--radius", type=float, default=0.22, help="圓角半徑(佔邊長比例)")
|
||||
ap.add_argument("--pick", default=None, help="largest|leftmost|rightmost|topmost|<由左至右的索引>")
|
||||
ap.add_argument("--radius", type=float, default=0.22)
|
||||
ap.add_argument("--pick", default=None)
|
||||
ap.add_argument("--face", default=None, help="直接指定臉的框 x,y,w,h")
|
||||
ap.add_argument("--list", action="store_true", help="只列出偵測到的臉,不輸出圖")
|
||||
ap.add_argument("--zoom", type=float, default=2.1, help="裁切框相對臉的倍率")
|
||||
ap.add_argument("--bg", default=None, help="compose 的底色漸層,例如 #d9a45b,#c0392b")
|
||||
ap.add_argument("--crop", default="head", choices=["head", "full"],
|
||||
help="compose 時裁頭肩(預設)還是用整張")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
fail("缺少 Pillow", "pip install pillow")
|
||||
|
||||
try:
|
||||
img = Image.open(args.input).convert("RGB")
|
||||
img = Image.open(args.input)
|
||||
img.load()
|
||||
except Exception as exc:
|
||||
fail(f"讀不到圖片:{exc}", "確認檔案完整;WebP 需要較新的 Pillow")
|
||||
|
||||
W, H = img.size
|
||||
faces, method = detect_faces(args.input, args.cascade)
|
||||
face = None
|
||||
if args.face:
|
||||
try:
|
||||
face = tuple(int(v) for v in args.face.split(","))
|
||||
method = "manual-box"
|
||||
except ValueError:
|
||||
fail("--face 格式要是 x,y,w,h")
|
||||
else:
|
||||
face = choose(faces, args.pick)
|
||||
|
||||
if args.list:
|
||||
if args.mode == "measure":
|
||||
bg = background_report(img)
|
||||
big = face[2] if face else 0
|
||||
has_alpha = False
|
||||
if img.mode in ("RGBA", "LA", "PA"):
|
||||
lo, hi = img.convert("RGBA").getchannel("A").getextrema()
|
||||
has_alpha = lo < 16 and hi > 200
|
||||
emit({
|
||||
"ok": True,
|
||||
"size": [W, H],
|
||||
"method": method,
|
||||
"faces": [
|
||||
{"index": i, "x": f[0], "y": f[1], "w": f[2], "h": f[3],
|
||||
"center": [f[0] + f[2] // 2, f[1] + f[3] // 2]}
|
||||
for i, f in enumerate(sorted(faces, key=lambda f: f[0]))
|
||||
],
|
||||
"ok": True, "size": [W, H], "pixels": W * H, "faces_found": len(faces),
|
||||
"face": list(face) if face else None, "face_ratio": round(big / max(W, H), 3) if face else 0,
|
||||
"method": method, "background": bg, "transparent": has_alpha,
|
||||
"cutout_easy": bool(has_alpha or bg["plain"]),
|
||||
})
|
||||
|
||||
if args.mode == "faces":
|
||||
emit({
|
||||
"ok": True, "size": [W, H], "method": method,
|
||||
"faces": [{"index": i, "x": f[0], "y": f[1], "w": f[2], "h": f[3],
|
||||
"center": [f[0] + f[2] // 2, f[1] + f[3] // 2]}
|
||||
for i, f in enumerate(sorted(faces, key=lambda f: f[0]))],
|
||||
})
|
||||
|
||||
if not args.output:
|
||||
fail("需要 --output(或用 --list 只看偵測結果)")
|
||||
fail("這個模式需要 --output")
|
||||
|
||||
if args.face:
|
||||
try:
|
||||
x, y, w, h = (int(v) for v in args.face.split(","))
|
||||
except ValueError:
|
||||
fail("--face 格式要是 x,y,w,h")
|
||||
method = "manual-box"
|
||||
else:
|
||||
chosen = choose(faces, args.pick)
|
||||
if chosen:
|
||||
x, y, w, h = chosen
|
||||
if args.mode == "headshot":
|
||||
from PIL import ImageDraw
|
||||
box = head_box((W, H), face, args.zoom)
|
||||
out = img.convert("RGB").crop(box).resize((args.size, args.size), Image.LANCZOS).convert("RGBA")
|
||||
radius = int(args.size * args.radius)
|
||||
mask = Image.new("L", (args.size, args.size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, args.size - 1, args.size - 1], radius=radius, fill=255)
|
||||
out.putalpha(mask)
|
||||
out.save(args.output, "PNG", optimize=True)
|
||||
emit({"ok": True, "mode": "headshot", "method": method, "faces_found": len(faces),
|
||||
"source_size": [W, H], "face": list(face) if face else None, "box": list(box),
|
||||
"size": args.size, "output": args.output})
|
||||
|
||||
if args.mode == "cutout":
|
||||
bg = background_report(img)
|
||||
# 最好的情況:官方人設圖多半本來就是透明底 PNG,直接沿用既有 alpha
|
||||
existing = None
|
||||
if img.mode in ("RGBA", "LA", "PA"):
|
||||
a = img.convert("RGBA").getchannel("A")
|
||||
lo, hi = a.getextrema()
|
||||
if lo < 16 and hi > 200:
|
||||
existing = a
|
||||
if existing is not None:
|
||||
alpha = existing
|
||||
used = "source-alpha"
|
||||
elif bg["plain"]:
|
||||
alpha = alpha_from_plain_bg(img, bg["color"])
|
||||
used = "plain-background"
|
||||
else:
|
||||
method = "heuristic-top-center"
|
||||
side = min(W, H)
|
||||
x, y, w, h = int(W / 2 - side * 0.25), int(min(H / 2, side * 0.30) - side * 0.25), \
|
||||
int(side * 0.5), int(side * 0.5)
|
||||
alpha = alpha_from_grabcut(args.input, face)
|
||||
used = "grabcut"
|
||||
if alpha is None:
|
||||
fail("這張圖的背景不是單色,而 OpenCV 不可用,無法去背",
|
||||
"換一張官方人設圖(通常是白底),或安裝 opencv-python-headless<5")
|
||||
rgba = img.convert("RGBA")
|
||||
rgba.putalpha(alpha)
|
||||
# 裁到實際內容的範圍,邊界不留大片透明
|
||||
bbox = rgba.getbbox()
|
||||
if bbox:
|
||||
rgba = rgba.crop(bbox)
|
||||
rgba.save(args.output, "PNG", optimize=True)
|
||||
hist = rgba.getchannel("A").histogram()
|
||||
opaque = sum(hist[129:])
|
||||
emit({"ok": True, "mode": "cutout", "method": used, "background": bg,
|
||||
"source_size": [W, H], "output_size": list(rgba.size),
|
||||
"opaque_ratio": round(opaque / (rgba.size[0] * rgba.size[1]), 3),
|
||||
"output": args.output})
|
||||
|
||||
# 往外留邊,讓頭髮與肩膀進來一點,構圖才像頭像
|
||||
pad = max(w, h) * 0.55
|
||||
cx, cy = x + w / 2, y + h / 2 - h * 0.06
|
||||
side = min(max(w, h) + pad * 2, min(W, H))
|
||||
left = int(max(0, min(W - side, cx - side / 2)))
|
||||
top = int(max(0, min(H - side, cy - side / 2)))
|
||||
box = (left, top, int(left + side), int(top + side))
|
||||
|
||||
face = img.crop(box).resize((args.size, args.size), Image.LANCZOS).convert("RGBA")
|
||||
radius = int(args.size * args.radius)
|
||||
mask = Image.new("L", (args.size, args.size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, args.size - 1, args.size - 1], radius=radius, fill=255)
|
||||
face.putalpha(mask)
|
||||
face.save(args.output, "PNG", optimize=True)
|
||||
|
||||
emit({
|
||||
"ok": True,
|
||||
"method": method,
|
||||
"faces_found": len(faces),
|
||||
"source_size": [W, H],
|
||||
"face": [x, y, w, h],
|
||||
"box": list(box),
|
||||
"size": args.size,
|
||||
"output": args.output,
|
||||
})
|
||||
if args.mode == "compose":
|
||||
from PIL import ImageDraw
|
||||
cut = img.convert("RGBA")
|
||||
S = args.size
|
||||
colors = [(90, 110, 150), (40, 50, 80)]
|
||||
if args.bg:
|
||||
parts = [p.strip().lstrip("#") for p in args.bg.split(",")]
|
||||
try:
|
||||
colors = [tuple(int(p[i:i + 2], 16) for i in (0, 2, 4)) for p in parts[:2]]
|
||||
except ValueError:
|
||||
pass
|
||||
if len(colors) == 1:
|
||||
colors *= 2
|
||||
canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
for i in range(S):
|
||||
t = i / max(1, S - 1)
|
||||
c = tuple(int(colors[0][k] + (colors[1][k] - colors[0][k]) * t) for k in range(3))
|
||||
draw.line([(0, i), (S, i)], fill=c + (255,))
|
||||
# 官方人設圖常是「正面+背面」兩張並排的設定稿,整張塞進去會變成兩個人。
|
||||
# 所以預設先裁到頭肩構圖(用臉的位置),要整張再指定 --crop full。
|
||||
if args.crop == "head":
|
||||
cfaces, _ = detect_faces(args.input, args.cascade)
|
||||
cface = choose(cfaces, args.pick)
|
||||
if args.face:
|
||||
try:
|
||||
cface = tuple(int(v) for v in args.face.split(","))
|
||||
except ValueError:
|
||||
pass
|
||||
if cface is not None:
|
||||
cut = cut.crop(head_box(cut.size, cface, args.zoom))
|
||||
cw, ch = cut.size
|
||||
scale = (S * 0.98) / max(cw, ch)
|
||||
nw, nh = max(1, int(cw * scale)), max(1, int(ch * scale))
|
||||
person = cut.resize((nw, nh), Image.LANCZOS)
|
||||
canvas.alpha_composite(person, (int((S - nw) / 2), int((S - nh) / 2)))
|
||||
radius = int(S * args.radius)
|
||||
mask = Image.new("L", (S, S), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, S - 1, S - 1], radius=radius, fill=255)
|
||||
out = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||
out.paste(canvas, (0, 0), mask)
|
||||
out.save(args.output, "PNG", optimize=True)
|
||||
emit({"ok": True, "mode": "compose", "size": S, "person": [nw, nh], "output": args.output})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user