Perler Pattern

Other

Convert any image into a perler/hama/fuse bead pattern. 把任意图片转换成精美的拼豆图纸。Use when the user wants to generate a bead pattern from a photo, image, or URL. Outpu...

Install

openclaw skills install perler-pattern

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 · 精细,适合大尺寸