Perler Bead Pattern Generator · 拼豆图纸生成器
把任意图片自动去背景,生成精美拼豆图纸。
内置 GrabCut 离线去背景,无需任何外部 API,零费用。
Palette reference → references/palettes.md
Style guide → references/styles.md
Trigger / 触发时机
- "Turn this photo into a perler bead pattern"
- "帮我把这张图片做成拼豆图纸"
- "Make a hama bead pattern from my pet photo"
- "把我家宠物的照片转成拼豆图纸"
- "Generate a 40×40 Artkal bead pattern"
- "用欧小色板生成拼豆图纸,深色背景"
Step 0: Dependency Check / 环境检查
# Check and install required packages
python3 -c "import cv2" 2>/dev/null || pip3 install opencv-python-headless --quiet
python3 -c "from PIL import Image" 2>/dev/null || pip3 install Pillow --quiet
python3 -c "import numpy" 2>/dev/null || pip3 install numpy --quiet
echo "✅ Ready"
Step 1: Extract Parameters / 提取参数
Image / 图片来源:
Local path 本地路径 or URL 网络链接
Grid size / 网格尺寸 (default 40×40):
"small" → 29×29 (one board / 一块板)
"medium" → 40×40 (default / 默认)
"large" → 48×48
"xlarge" → 64×64
Palette / 色板 (default: hama):
hama / artkal / perler / universal
Background color / 背景色 (default: dark_navy):
dark_navy → (15, 15, 40) 深海军蓝,最常见
black → (5, 5, 5) 纯黑
white → (255,255,255) 白色(适合浅色主体)
custom → 用户指定 RGB
Max colors / 颜色数 (default: 25):
"simple" → 15
"standard" → 25
"detailed" → 32
Step 2: Run / 执行脚本
Replace all __PARAM__ placeholders before running.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Perler Bead Pattern Generator v1.2 — Auto background removal via GrabCut"""
import os, sys, json, math
import cv2
import numpy as np
from pathlib import Path
from PIL import Image, ImageEnhance, ImageFilter, ImageDraw
# ── Parameters (filled by OpenClaw) ──────────────────────
IMAGE_PATH = "__IMAGE_PATH__"
GRID_W = 40
GRID_H = 40
MAX_COLORS = 25
PALETTE_NAME = "hama" # hama / artkal / perler / universal
BG_COLOR = (15, 15, 40) # deep navy blue
OUTPUT_DIR = "__OUTPUT_DIR__"
# ── Guard ────────────────────────────────────────────────
if "__IMAGE_PATH__" in IMAGE_PATH or not IMAGE_PATH:
print("❌ IMAGE_PATH not set."); sys.exit(1)
os.makedirs(OUTPUT_DIR, exist_ok=True)
# ── Palettes ─────────────────────────────────────────────
PALETTES = {
"hama": {
"H01":("White/白", (255,255,255)), "H02":("Cream/奶油", (255,253,208)),
"H03":("Lt Yellow/淡黄", (255,239, 96)), "H04":("Yellow/黄", (255,216, 0)),
"H05":("Orange/橙", (255,140, 0)), "H06":("Red-Orange/橙红",(230, 75, 0)),
"H07":("Red/红", (210, 20, 20)), "H08":("Dark Red/暗红", (155, 0, 0)),
"H09":("Lt Pink/浅粉", (255,190,210)), "H10":("Pink/粉", (255,130,170)),
"H11":("Rose/玫红", (220, 45,120)), "H12":("Purple/紫", (128, 28,156)),
"H13":("Violet/紫罗兰", ( 88, 18,196)), "H14":("Lavender/薰衣草",(200,168,255)),
"H15":("Lt Blue/天蓝", (130,198,255)), "H16":("Blue/蓝", ( 0, 96,200)),
"H17":("Dark Blue/深蓝", ( 0, 28,138)), "H18":("Turquoise/青", ( 0,178,178)),
"H19":("Dark Green/深绿", ( 0, 88, 0)), "H20":("Green/绿", ( 28,158, 28)),
"H21":("Lime/黄绿", (146,218, 0)), "H22":("Mint/薄荷", (168,255,210)),
"H23":("Dark Brown/深棕", ( 78, 38, 0)), "H24":("Brown/棕", (138, 68, 18)),
"H25":("Tan/浅棕", (198,152, 88)), "H26":("Skin/肤色", (255,208,168)),
"H27":("Peach/桃", (255,178,140)), "H28":("Coral/珊瑚", (255,108,100)),
"H29":("Lt Grey/浅灰", (210,210,210)), "H30":("Grey/灰", (148,148,148)),
"H31":("Dark Grey/深灰", ( 78, 78, 78)), "H32":("Black/黑", ( 10, 10, 10)),
"H33":("Gold/金", (218,178, 0)), "H34":("Silver/银", (188,188,188)),
"H35":("Neon Yellow/荧光黄",(226,255,0)),"H36":("Neon Pink/荧光粉",(255,18,144)),
"H37":("Neon Green/荧光绿",( 0,252, 78)),"H38":("Neon Orange/荧光橙",(255,100,0)),
},
"artkal": {
"C01":("White/白",(255,255,255)),"C02":("Cream/奶油",(255,248,208)),
"C03":("Lt Yellow/淡黄",(255,232,88)),"C04":("Yellow/黄",(255,212,0)),
"C05":("Orange/橙",(255,138,0)),"C06":("Red-Orange/橙红",(228,68,0)),
"C07":("Red/红",(208,14,14)),"C08":("Dark Red/深红",(138,0,0)),
"C09":("Lt Pink/浅粉",(255,172,192)),"C10":("Rose/玫红",(222,38,118)),
"C11":("Purple/紫",(118,24,152)),"C12":("Dark Purple/深紫",(78,0,118)),
"C13":("Sky Blue/天蓝",(128,192,255)),"C14":("Blue/蓝",(0,88,192)),
"C15":("Navy/深蓝",(0,18,128)),"C16":("Cyan/青",(0,172,172)),
"C17":("Green/绿",(24,152,24)),"C18":("Dark Green/深绿",(0,78,0)),
"C19":("Yellow-Green/黄绿",(138,212,0)),"C20":("Brown/棕",(132,62,14)),
"C21":("Dark Brown/深棕",(72,32,0)),"C22":("Tan/浅棕",(192,148,82)),
"C23":("Skin/肤色",(248,202,162)),"C24":("Coral/珊瑚",(248,122,102)),
"C25":("Lt Grey/浅灰",(208,208,208)),"C26":("Grey/灰",(142,142,142)),
"C27":("Dark Grey/深灰",(72,72,72)),"C28":("Black/黑",(4,4,4)),
"C29":("Gold/金",(212,172,0)),"C30":("Lavender/薰衣草",(202,172,248)),
},
"perler": {
"P01":("White/白",(255,255,255)),"P02":("Cream/奶油",(255,246,218)),
"P03":("Yellow/黄",(255,222,0)),"P04":("Orange/橙",(255,118,0)),
"P05":("Red/红",(212,0,14)),"P06":("Lt Pink/浅粉",(255,168,188)),
"P07":("Pink/粉",(255,108,158)),"P08":("Rose/玫红",(218,42,118)),
"P09":("Purple/紫",(122,28,152)),"P10":("Lt Blue/天蓝",(122,188,255)),
"P11":("Blue/蓝",(0,82,188)),"P12":("Dark Blue/深蓝",(0,22,128)),
"P13":("Teal/青",(0,162,162)),"P14":("Green/绿",(18,148,18)),
"P15":("Dark Green/深绿",(0,72,0)),"P16":("Kiwi/黄绿",(142,208,0)),
"P17":("Brown/棕",(128,58,8)),"P18":("Peach/桃",(255,198,158)),
"P19":("Lt Grey/浅灰",(208,208,208)),"P20":("Grey/灰",(138,138,138)),
"P21":("Black/黑",(0,0,0)),"P22":("Gold/金",(208,168,0)),
"P23":("Silver/银",(182,182,182)),"P24":("Clear/透明",(238,238,238)),
},
"universal": {
"U01":("White/白",(255,255,255)),"U02":("Ivory/米白",(255,248,210)),
"U03":("Lt Yellow/浅黄",(255,238,82)),"U04":("Yellow/黄",(255,212,0)),
"U05":("Amber/琥珀",(222,178,0)),"U06":("Lt Orange/浅橙",(255,168,58)),
"U07":("Orange/橙",(255,110,0)),"U08":("Dk Orange/深橙",(218,68,0)),
"U09":("Salmon/鲑鱼",(255,120,100)),"U10":("Red/红",(210,0,0)),
"U11":("Dark Red/深红",(138,0,0)),"U12":("Lt Pink/浅粉",(255,198,212)),
"U13":("Pink/粉",(255,148,178)),"U14":("Hot Pink/玫红",(218,38,118)),
"U15":("Magenta/品红",(198,0,148)),"U16":("Lavender/薰衣草",(208,178,255)),
"U17":("Purple/紫",(128,28,158)),"U18":("Dk Purple/深紫",(78,0,118)),
"U19":("Baby Blue/婴儿蓝",(178,222,255)),"U20":("Sky Blue/天蓝",(98,178,255)),
"U21":("Blue/蓝",(0,88,198)),"U22":("Dark Blue/深蓝",(0,22,138)),
"U23":("Cyan/青",(0,178,178)),"U24":("Dark Cyan/深青",(0,118,118)),
"U25":("Lt Green/浅绿",(148,228,148)),"U26":("Green/绿",(28,158,28)),
"U27":("Dark Green/深绿",(0,88,0)),"U28":("Yellow Green/黄绿",(152,222,0)),
"U29":("Lt Brown/浅棕",(208,168,108)),"U30":("Brown/棕",(148,78,22)),
"U31":("Dark Brown/深棕",(88,42,0)),"U32":("Skin/肤色",(255,208,168)),
"U33":("Peach/桃",(255,178,138)),"U34":("Lt Grey/浅灰",(208,208,208)),
"U35":("Grey/灰",(148,148,148)),"U36":("Dark Grey/深灰",(78,78,78)),
"U37":("Black/黑",(4,4,4)),"U38":("Gold/金",(218,178,0)),
"U39":("Silver/银",(190,190,190)),"U40":("Neon Yellow/荧光黄",(228,255,0)),
"U41":("Neon Pink/荧光粉",(255,18,144)),"U42":("Neon Green/荧光绿",(0,252,78)),
}
}
# ── Color math: Lab color space ───────────────────────────
def rgb_to_lab(rgb):
r,g,b = [c/255.0 for c in rgb]
def lin(c): return c/12.92 if c<=0.04045 else ((c+0.055)/1.055)**2.4
r,g,b = lin(r),lin(g),lin(b)
X=r*0.4124564+g*0.3575761+b*0.1804375
Y=r*0.2126729+g*0.7151522+b*0.0721750
Z=r*0.0193339+g*0.1191920+b*0.9503041
X/=0.95047; Z/=1.08883
def f(t): return t**(1/3) if t>0.008856 else 7.787*t+16/116
fx,fy,fz=f(X),f(Y),f(Z)
return 116*fy-16, 500*(fx-fy), 200*(fy-fz)
def lab_dist(a,b): return sum((x-y)**2 for x,y in zip(a,b))**0.5
def build_lab_pal(palette):
return {code:(info[0],info[1],rgb_to_lab(info[1])) for code,info in palette.items()}
def nearest_color(rgb, lab_pal):
lab=rgb_to_lab(rgb)
best,dist=None,float('inf')
for code,(_,pal_rgb,pal_lab) in lab_pal.items():
d=lab_dist(lab,pal_lab)
if d<dist: dist,best=d,code
_,pal_rgb,_=lab_pal[best]
return best,pal_rgb
# ── Load image ────────────────────────────────────────────
def load_image(path):
if path.startswith(("http://","https://")):
import urllib.request,tempfile
ext=".jpg" if "jpg" in path.lower() else ".png"
tmp=tempfile.mktemp(suffix=ext)
urllib.request.urlretrieve(path,tmp)
path=tmp
return cv2.cvtColor(cv2.imread(path), cv2.COLOR_BGR2RGB)
# ── Auto background removal (GrabCut, fully offline) ─────
def remove_background(img_rgb, bg_color=(15,15,40)):
h,w=img_rgb.shape[:2]
# Init rect: 10% margins, avoids edge artifacts
mx,my=int(w*0.10),int(h*0.08)
rect=(mx,my,w-2*mx,h-2*my)
mask=np.zeros((h,w),np.uint8)
bgd=np.zeros((1,65),np.float64)
fgd=np.zeros((1,65),np.float64)
cv2.grabCut(img_rgb,mask,rect,bgd,fgd,7,cv2.GC_INIT_WITH_RECT)
fg=np.where((mask==cv2.GC_FGD)|(mask==cv2.GC_PR_FGD),255,0).astype(np.uint8)
# Smooth edges
fg=cv2.GaussianBlur(fg,(3,3),0)
_,fg=cv2.threshold(fg,128,255,cv2.THRESH_BINARY)
# Fill holes, remove specks
fg=cv2.morphologyEx(fg,cv2.MORPH_CLOSE,np.ones((9,9),np.uint8))
fg=cv2.morphologyEx(fg,cv2.MORPH_OPEN, np.ones((3,3),np.uint8))
result=img_rgb.copy()
result[fg==0]=bg_color
return result
# ── Resize center-crop ────────────────────────────────────
def resize_crop(img_rgb,w,h):
ih,iw=img_rgb.shape[:2]
scale=max(w/iw,h/ih)
nw,nh=math.ceil(iw*scale),math.ceil(ih*scale)
img_pil=Image.fromarray(img_rgb).resize((nw,nh),Image.LANCZOS)
left=(nw-w)//2; top=(nh-h)//2
return np.array(img_pil.crop((left,top,left+w,top+h)))
# ── Lab-based brightness equalization ────────────────────
def lab_equalize(arr):
def r2l(a):
rgb=a.astype(np.float32)/255.0
m=rgb>0.04045
rgb=np.where(m,((rgb+0.055)/1.055)**2.4,rgb/12.92)
M=np.array([[0.4124564,0.3575761,0.1804375],[0.2126729,0.7151522,0.0721750],[0.0193339,0.1191920,0.9503041]])
xyz=rgb@M.T/[0.95047,1,1.08883]
m2=xyz>0.008856
xyz=np.where(m2,xyz**(1/3),7.787*xyz+16/116)
return np.stack([116*xyz[:,:,1]-16,500*(xyz[:,:,0]-xyz[:,:,1]),200*(xyz[:,:,1]-xyz[:,:,2])],2)
def l2r(lab):
L,a,b=lab[:,:,0],lab[:,:,1],lab[:,:,2]
fy=(L+16)/116;fx=a/500+fy;fz=fy-b/200
xyz=np.stack([fx,fy,fz],2)
m=xyz>0.2068966
xyz=np.where(m,xyz**3,(xyz-16/116)/7.787)*[0.95047,1,1.08883]
Mi=np.array([[3.2404542,-1.5371385,-0.4985314],[-0.9692660,1.8760108,0.0415560],[0.0556434,-0.2040259,1.0572252]])
rgb=np.clip(xyz@Mi.T,0,1)
m2=rgb>0.0031308
return np.clip(np.where(m2,1.055*rgb**(1/2.4)-0.055,12.92*rgb)*255,0,255).astype(np.uint8)
lab=r2l(arr)
L=lab[:,:,0]
p2,p98=np.percentile(L,2),np.percentile(L,98)
if p98-p2>5:
lab[:,:,0]=np.clip((L-p2)/(p98-p2)*100,0,100)
return l2r(lab)
# ── Map pixels to palette ─────────────────────────────────
def map_to_palette(arr,lab_pal,max_colors):
h,w=arr.shape[:2]
cache={}
grid=[[None]*w for _ in range(h)]
usage={}
for y in range(h):
for x in range(w):
rgb=tuple(arr[y,x])
if rgb not in cache:
code,pal_rgb=nearest_color(rgb,lab_pal)
name=lab_pal[code][0]
cache[rgb]=(code,name,pal_rgb)
code,name,pal_rgb=cache[rgb]
grid[y][x]=code
if code not in usage:
usage[code]={"name":name,"rgb":pal_rgb,"count":0}
usage[code]["count"]+=1
# Enforce max_colors: merge least-used into nearest
while len(usage)>max_colors:
least=min(usage,key=lambda c:usage[c]["count"])
ll=rgb_to_lab(usage[least]["rgb"])
best_alt,bd=None,float('inf')
for c in usage:
if c==least: continue
d=lab_dist(ll,rgb_to_lab(usage[c]["rgb"]))
if d<bd: bd,best_alt=d,c
for y in range(h):
for x in range(w):
if grid[y][x]==least: grid[y][x]=best_alt
usage[best_alt]["count"]+=usage[least]["count"]
del usage[least]
grid_rgb=np.zeros((h,w,3),dtype=np.uint8)
for y in range(h):
for x in range(w):
grid_rgb[y,x]=usage[grid[y][x]]["rgb"]
return grid,grid_rgb,usage
# ── SVG output ────────────────────────────────────────────
def gen_svg(grid,usage,cell=16):
rows,cols=len(grid),len(grid[0])
W,H=cols*cell,rows*cell
fs=max(5,cell//3)
L=['<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">',
f'<style>text{{font-family:Arial;font-size:{fs}px;}}</style>']
for y,row in enumerate(grid):
for x,code in enumerate(row):
r,g,b=usage[code]["rgb"]
fill=f"#{r:02x}{g:02x}{b:02x}"
lum=0.299*r+0.587*g+0.114*b
tc="#fff" if lum<128 else "#000"
px,py=x*cell,y*cell
L.append(f'<rect x="{px}" y="{py}" width="{cell}" height="{cell}" fill="{fill}" stroke="#ccc" stroke-width="0.4"/>')
if cell>=10:
num=''.join(c for c in code if c.isdigit())
L.append(f'<text x="{px+cell//2}" y="{py+cell//2}" fill="{tc}" text-anchor="middle" dominant-baseline="central">{num}</text>')
for i in range(0,cols+1,10): L.append(f'<line x1="{i*cell}" y1="0" x2="{i*cell}" y2="{H}" stroke="#888" stroke-width="0.8"/>')
for i in range(0,rows+1,10): L.append(f'<line x1="0" y1="{i*cell}" x2="{W}" y2="{i*cell}" stroke="#888" stroke-width="0.8"/>')
L.append(f'<rect x="0" y="0" width="{W}" height="{H}" fill="none" stroke="#222" stroke-width="1.5"/>')
L.append('</svg>')
return '\n'.join(L)
# ── 3D Bead preview PNG ───────────────────────────────────
def gen_bead_preview(arr_q,bg_color,cell=20):
rows,cols=arr_q.shape[:2]
canvas=Image.new("RGB",(cols*cell,rows*cell),bg_color)
draw=ImageDraw.Draw(canvas)
for y in range(rows):
for x in range(cols):
r,g,b=int(arr_q[y,x,0]),int(arr_q[y,x,1]),int(arr_q[y,x,2])
cx,cy=x*cell+cell//2,y*cell+cell//2
pad=1
# Main bead body
draw.ellipse([cx-cell//2+pad,cy-cell//2+pad,cx+cell//2-pad,cy+cell//2-pad],fill=(r,g,b))
# Bottom-right shadow (same hue, darker)
dark=(max(0,r-55),max(0,g-55),max(0,b-55))
draw.arc([cx-cell//2+pad+1,cy-cell//2+pad+1,cx+cell//2-pad-1,cy+cell//2-pad-1],
start=30,end=210,fill=dark,width=2)
# Top-left highlight (lighter)
hs=cell//5
hi=(min(255,r+90),min(255,g+90),min(255,b+90))
draw.ellipse([cx-hs,cy-hs,cx,cy],fill=hi)
return canvas
# ── Interactive HTML viewer ───────────────────────────────
def gen_html(grid,usage,palette_name,bg_color):
rows,cols=len(grid),len(grid[0])
total=sum(v["count"] for v in usage.values())
bg_hex="#{:02x}{:02x}{:02x}".format(*bg_color)
cells_js=json.dumps([[grid[y][x] for x in range(cols)] for y in range(rows)])
colors_js=json.dumps([
{"code":c,"name":v["name"],"hex":"#{:02x}{:02x}{:02x}".format(*v["rgb"]),"count":v["count"]}
for c,v in sorted(usage.items(),key=lambda x:-x[1]["count"])
])
return f"""<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Perler Pattern · 拼豆图纸 {cols}×{rows}</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:'PingFang SC','Segoe UI',Arial,sans-serif;background:#f0f0f0}}
header{{background:linear-gradient(135deg,#6c63ff,#3ec6e0);color:#fff;padding:18px 24px}}
header h1{{font-size:20px;margin-bottom:3px}}
header p{{font-size:12px;opacity:.85}}
.wrap{{max-width:1280px;margin:0 auto;padding:16px}}
.bar{{background:#fff;border-radius:10px;padding:12px 16px;margin-bottom:12px;
box-shadow:0 2px 8px rgba(0,0,0,.07);display:flex;gap:14px;align-items:center;flex-wrap:wrap}}
.bar label{{font-size:13px;color:#555;display:flex;align-items:center;gap:6px}}
input[type=range]{{width:100px}}
.btn{{padding:7px 14px;border-radius:7px;border:none;cursor:pointer;font-size:13px;font-weight:600}}
.btn-p{{background:#6c63ff;color:#fff}}.btn-s{{background:#e8e8e8;color:#333}}
.btn:hover{{opacity:.85}}
.layout{{display:grid;grid-template-columns:1fr 290px;gap:12px}}
@media(max-width:780px){{.layout{{grid-template-columns:1fr}}}}
.cbox{{background:#fff;border-radius:10px;padding:14px;box-shadow:0 2px 8px rgba(0,0,0,.07);overflow:auto}}
canvas{{cursor:crosshair;display:block}}
.side{{display:flex;flex-direction:column;gap:12px}}
.card{{background:#fff;border-radius:10px;padding:14px;box-shadow:0 2px 8px rgba(0,0,0,.07)}}
.card h3{{font-size:13px;font-weight:700;color:#333;margin-bottom:10px}}
.stats{{display:grid;grid-template-columns:1fr 1fr;gap:8px}}
.stat{{background:#f7f7f7;border-radius:8px;padding:10px;text-align:center}}
.stat-n{{font-size:18px;font-weight:700;color:#6c63ff}}
.stat-l{{font-size:10px;color:#888;margin-top:2px}}
.clist{{max-height:420px;overflow-y:auto}}
.ci{{display:flex;align-items:center;gap:8px;padding:5px 4px;border-bottom:1px solid #f0f0f0;cursor:pointer;border-radius:5px}}
.ci:hover{{background:#f5f5ff}}.ci.active{{background:#eeeeff;outline:2px solid #6c63ff}}
.sw{{width:22px;height:22px;border-radius:4px;border:1px solid #ddd;flex-shrink:0}}
.cc{{font-size:10px;font-weight:700;color:#6c63ff;width:38px}}
.cn{{font-size:11px;color:#555;flex:1;line-height:1.3}}
.cnt{{font-size:10px;color:#999;white-space:nowrap}}
.tip{{position:fixed;background:rgba(30,30,30,.88);color:#fff;padding:6px 10px;border-radius:6px;
font-size:11px;pointer-events:none;display:none;z-index:999;line-height:1.5}}
@media print{{header,.bar,.side{{display:none!important}}
.wrap{{padding:0;max-width:100%}}.cbox{{box-shadow:none;padding:0;border-radius:0}}}}
</style></head><body>
<header>
<h1>🧩 Perler Bead Pattern · 拼豆图纸</h1>
<p>{cols}×{rows} · {palette_name.upper()} · {len(usage)} colors · {total:,} beads</p>
</header>
<div class="wrap">
<div class="bar">
<label>Cell 格子 <input type="range" id="sz" min="6" max="32" value="14"><span id="szv">14px</span></label>
<label><input type="checkbox" id="nums" checked> Numbers 编号</label>
<label><input type="checkbox" id="grid" checked> Grid 网格</label>
<button class="btn btn-p" onclick="dlSVG()">⬇ SVG</button>
<button class="btn btn-p" onclick="dlPNG()">⬇ PNG</button>
<button class="btn btn-s" onclick="window.print()">🖨 Print</button>
<button class="btn btn-s" onclick="hl=null;draw()">✖ Clear</button>
</div>
<div class="layout">
<div class="cbox"><canvas id="c"></canvas></div>
<div class="side">
<div class="card">
<h3>📊 Stats · 统计</h3>
<div class="stats">
<div class="stat"><div class="stat-n">{cols}×{rows}</div><div class="stat-l">Grid 网格</div></div>
<div class="stat"><div class="stat-n">{total:,}</div><div class="stat-l">Beads 豆数</div></div>
<div class="stat"><div class="stat-n">{len(usage)}</div><div class="stat-l">Colors 颜色</div></div>
<div class="stat"><div class="stat-n" style="font-size:13px">{palette_name.upper()}</div><div class="stat-l">Palette 色板</div></div>
</div>
</div>
<div class="card">
<h3>🎨 Bead List · 用豆清单</h3>
<div class="clist" id="cl"></div>
</div>
</div></div></div>
<div class="tip" id="tip"></div>
<script>
const CELLS={cells_js},COLORS={colors_js};
const ROWS={rows},COLS={cols},BG="{bg_hex}";
const canvas=document.getElementById('c'),ctx=canvas.getContext('2d');
const tip=document.getElementById('tip');
let cell=14,showN=true,showG=true,hl=null;
function draw(){{
canvas.width=COLS*cell;canvas.height=ROWS*cell;
ctx.fillStyle=BG;ctx.fillRect(0,0,canvas.width,canvas.height);
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){{
const code=CELLS[y][x],ci=COLORS.find(c=>c.code===code);
ctx.fillStyle=(hl&&code!==hl)?'rgba(128,128,128,0.3)':ci.hex;
ctx.fillRect(x*cell,y*cell,cell,cell);
if(showG){{ctx.strokeStyle='rgba(0,0,0,0.15)';ctx.lineWidth=.4;ctx.strokeRect(x*cell,y*cell,cell,cell);}}
if(showN&&cell>=10){{
const lum=parseInt(ci.hex.slice(1,3),16)*.299+parseInt(ci.hex.slice(3,5),16)*.587+parseInt(ci.hex.slice(5,7),16)*.114;
ctx.fillStyle=lum<128?'#fff':'#000';
ctx.font=`${{Math.max(5,Math.floor(cell/3))}}px Arial`;
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(code.replace(/\\D/g,''),x*cell+cell/2,y*cell+cell/2);
}}
}}
ctx.strokeStyle='#888';ctx.lineWidth=.9;
for(let i=0;i<=COLS;i+=10){{ctx.beginPath();ctx.moveTo(i*cell,0);ctx.lineTo(i*cell,ROWS*cell);ctx.stroke();}}
for(let i=0;i<=ROWS;i+=10){{ctx.beginPath();ctx.moveTo(0,i*cell);ctx.lineTo(COLS*cell,i*cell);ctx.stroke();}}
ctx.strokeStyle='#333';ctx.lineWidth=1.8;ctx.strokeRect(0,0,COLS*cell,ROWS*cell);
}}
function buildList(){{
document.getElementById('cl').innerHTML=COLORS.map(c=>`
<div class="ci" id="ci_${{c.code}}" onclick="toggleHL('${{c.code}}')">
<div class="sw" style="background:${{c.hex}}"></div>
<div class="cc">${{c.code}}</div><div class="cn">${{c.name}}</div>
<div class="cnt">${{c.count.toLocaleString()}}</div>
</div>`).join('');
}}
function toggleHL(code){{
hl=(hl===code)?null:code;
document.querySelectorAll('.ci').forEach(e=>e.classList.remove('active'));
if(hl)document.getElementById('ci_'+code)?.classList.add('active');
draw();
}}
canvas.addEventListener('mousemove',e=>{{
const r=canvas.getBoundingClientRect(),x=Math.floor((e.clientX-r.left)/cell),y=Math.floor((e.clientY-r.top)/cell);
if(x>=0&&x<COLS&&y>=0&&y<ROWS){{
const code=CELLS[y][x],ci=COLORS.find(c=>c.code===code);
tip.style.display='block';tip.style.left=(e.clientX+14)+'px';tip.style.top=(e.clientY-10)+'px';
tip.innerHTML=`<b>${{code}}</b> ${{ci.name}}<br>(${{x+1}},${{y+1}}) · ${{ci.count}} beads`;
}}
}});
canvas.addEventListener('mouseleave',()=>tip.style.display='none');
document.getElementById('sz').addEventListener('input',e=>{{cell=+e.target.value;document.getElementById('szv').textContent=cell+'px';draw();}});
document.getElementById('nums').addEventListener('change',e=>{{showN=e.target.checked;draw();}});
document.getElementById('grid').addEventListener('change',e=>{{showG=e.target.checked;draw();}});
function dlSVG(){{
const fs=Math.max(5,Math.floor(cell/3));
let s=`<svg xmlns="http://www.w3.org/2000/svg" width="${{COLS*cell}}" height="${{ROWS*cell}}" viewBox="0 0 ${{COLS*cell}} ${{ROWS*cell}}">`;
s+=`<style>text{{font-family:Arial;font-size:${{fs}}px;}}</style>`;
s+=`<rect width="${{COLS*cell}}" height="${{ROWS*cell}}" fill="{bg_hex}"/>`;
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){{
const code=CELLS[y][x],ci=COLORS.find(c=>c.code===code);
const lum=parseInt(ci.hex.slice(1,3),16)*.299+parseInt(ci.hex.slice(3,5),16)*.587+parseInt(ci.hex.slice(5,7),16)*.114;
const tc=lum<128?'#fff':'#000',px=x*cell,py=y*cell;
s+=`<rect x="${{px}}" y="${{py}}" width="${{cell}}" height="${{cell}}" fill="${{ci.hex}}" stroke="rgba(0,0,0,0.15)" stroke-width="0.4"/>`;
if(cell>=10){{const num=code.replace(/\\D/g,'');s+=`<text x="${{px+cell/2}}" y="${{py+cell/2}}" fill="${{tc}}" text-anchor="middle" dominant-baseline="central">${{num}}</text>`;}}
}}
for(let i=0;i<=COLS;i+=10)s+=`<line x1="${{i*cell}}" y1="0" x2="${{i*cell}}" y2="${{ROWS*cell}}" stroke="#888" stroke-width="0.8"/>`;
for(let i=0;i<=ROWS;i+=10)s+=`<line x1="0" y1="${{i*cell}}" x2="${{COLS*cell}}" y2="${{i*cell}}" stroke="#888" stroke-width="0.8"/>`;
s+=`<rect x="0" y="0" width="${{COLS*cell}}" height="${{ROWS*cell}}" fill="none" stroke="#333" stroke-width="1.8"/>`;
s+='</svg>';
const a=document.createElement('a');a.href='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(s);a.download='perler.svg';a.click();
}}
function dlPNG(){{const a=document.createElement('a');a.href=canvas.toDataURL('image/png');a.download='perler.png';a.click();}}
draw();buildList();
</script></body></html>"""
# ── Bead list text ────────────────────────────────────────
def gen_bead_list(usage,palette_name,gw,gh):
total=sum(v["count"] for v in usage.values())
lines=[f"Perler Bead List · 用豆清单",f"Palette: {palette_name.upper()} Grid: {gw}×{gh} Colors: {len(usage)}",
"="*62,f"{'Code':<8}{'Name':<28}{'Count':>6} {'Hex'}","-"*62]
for code,v in sorted(usage.items(),key=lambda x:-x[1]["count"]):
r,g,b=v["rgb"]
lines.append(f"{code:<8}{v['name']:<28}{v['count']:>4} pcs #{r:02x}{g:02x}{b:02x}")
lines+=["="*62,f"{'Total / 合计':<36}{total:>4} pcs"]
return '\n'.join(lines)
# ── MAIN ──────────────────────────────────────────────────
print("🧩 Perler Pattern Generator v1.2 starting...")
if not Path(IMAGE_PATH).exists() and not IMAGE_PATH.startswith("http"):
print(f"❌ Not found: {IMAGE_PATH}"); sys.exit(1)
print("📷 Loading image...")
img_rgb = load_image(IMAGE_PATH)
print(f" Size: {img_rgb.shape[1]}×{img_rgb.shape[0]}")
print("✂️ Removing background (GrabCut, fully offline)...")
img_nobg = remove_background(img_rgb, BG_COLOR)
print(" Done")
print(f"📐 Resizing to {GRID_W}×{GRID_H}...")
img_small = resize_crop(img_nobg, GRID_W, GRID_H)
print("🌈 Equalizing brightness (Lab color space)...")
img_eq = lab_equalize(img_small)
print("🔪 Sharpening edges...")
img_pil = Image.fromarray(img_eq)
img_pil = img_pil.filter(ImageFilter.UnsharpMask(radius=1.2, percent=120, threshold=2))
img_pil = ImageEnhance.Contrast(img_pil).enhance(1.25)
img_arr = np.array(img_pil)
print(f"🎨 Quantizing → {PALETTE_NAME} palette ({MAX_COLORS} colors)...")
img_q = img_pil.quantize(colors=MAX_COLORS, method=Image.Quantize.MEDIANCUT, dither=0).convert("RGB")
palette = PALETTES.get(PALETTE_NAME, PALETTES["hama"])
lab_pal = build_lab_pal(palette)
grid, grid_rgb, usage = map_to_palette(np.array(img_q), lab_pal, MAX_COLORS)
print(f" Actual colors used: {len(usage)}")
print("💾 Generating output files...")
svg = gen_svg(grid, usage, cell=16)
p = os.path.join(OUTPUT_DIR, "pattern.svg")
open(p,"w",encoding="utf-8").write(svg); print(f" ✅ SVG: {p}")
html = gen_html(grid, usage, PALETTE_NAME, BG_COLOR)
p = os.path.join(OUTPUT_DIR, "pattern.html")
open(p,"w",encoding="utf-8").write(html); print(f" ✅ HTML: {p}")
bead_canvas = gen_bead_preview(grid_rgb, BG_COLOR, cell=20)
p = os.path.join(OUTPUT_DIR, "preview_beads.png")
bead_canvas.save(p); print(f" ✅ Bead preview: {p}")
flat = Image.fromarray(grid_rgb).resize((GRID_W*16, GRID_H*16), Image.NEAREST)
p = os.path.join(OUTPUT_DIR, "preview_flat.png")
flat.save(p); print(f" ✅ Flat preview: {p}")
blist = gen_bead_list(usage, PALETTE_NAME, GRID_W, GRID_H)
p = os.path.join(OUTPUT_DIR, "bead_list.txt")
open(p,"w",encoding="utf-8").write(blist); print(f" ✅ Bead list: {p}")
total=sum(v["count"] for v in usage.values())
print(f"""
╔════════════════════════════════════════╗
║ 🧩 Pattern Ready! · 拼豆图纸生成完成 ║
╠════════════════════════════════════════╣
║ Grid : {GRID_W}×{GRID_H} Colors: {len(usage)} Beads: {total:,}
║ Palette: {PALETTE_NAME.upper()}
╠════════════════════════════════════════╣
║ pattern.html ← 浏览器查看/打印
║ pattern.svg ← 可打印矢量图纸
║ preview_beads.png ← 3D豆子效果预览
║ preview_flat.png ← 平铺效果预览
║ bead_list.txt ← 购买清单
╚════════════════════════════════════════╝
Output: {OUTPUT_DIR}
""")
print(blist)
Step 3: Conversation / 多轮对话
"background messy / 背景去除不干净" → ask user to pre-crop or use phone app to remove bg first
"more colors / 颜色太少" → increase MAX_COLORS (+5), re-run
"fewer colors / 颜色太多" → decrease MAX_COLORS (-5), re-run
"change to black bg / 换黑色背景" → BG_COLOR=(5,5,5), re-run
"white background / 白色背景" → BG_COLOR=(255,255,255), re-run
"switch palette / 换色板" → change PALETTE_NAME, re-run
"how many beads / 需要多少豆" → show bead_list.txt
Tips / 使用建议
Best images 最佳图片类型:
- ✅ Pets / animals on simple background · 简单背景的宠物
- ✅ Cartoon / anime characters · 卡通角色
- ✅ Logos, icons · Logo图标
- ✅ Pre-removed background (PNG with transparency) · 已去背景的PNG
If GrabCut result is poor / 如果去背景效果不好:
- Use phone app first: iOS "Remove Background" / Android "Background Eraser"
- 先用手机App去背景,效果会更好
- Or crop image to focus on subject only · 先裁剪让主体占满画面
Color count guide / 颜色数建议:
- 15: Simple, easy to assemble · 简单,容易拼
- 25: Standard, good detail · 标准,细节好(默认)
- 32: Detailed, complex · 精细,适合大尺寸