Python โดย ผู้ดูแลระบบ อ่าน 8 นาที

แยก Sprite Sheet เป็น Game Assets ด้วย Python: คู่มือตัดภาพอัตโนมัติแบบ Pixel Perfect

มาเขียนสคริปต์ Python แยก Sprite Sheet ออกเป็นไฟล์ย่อยแบบอัตโนมัติ ลบพื้นหลังตารางสีขาว-เทา รักษา Pixel Art 1:1 พร้อมจัดหมวดหมู่และสร้าง metadata ครบจบในที่เดียว

สารบัญ

คุณเคยนั่งตัด Sprite Sheet ทีละชิ้นด้วยมือจนปวดตาไหม? การตัดภาพด้วยมือไม่เพียงแต่เสียเวลา แต่ยังเสี่ยงตัดขาดขอบเงาหรืออาวุธที่จำเป็นต่อเกมของคุณ บทความนี้จะพาคุณสร้างสคริปต์ Python ที่ทำงานแทนคุณแบบอัตโนมัติ ลบพื้นหลังลายตาราง รักษา Pixel Art 1:1 และจัดหมวดหมู่ไฟล์ให้พร้อมใช้งานในเกมอย่างสมบูรณ์แบบ

ทำความเข้าใจปัญหาและเตรียมสภาพแวดล้อม

การแยก Sprite Sheet อัตโนมัติมีความท้าทายหลายอย่าง เช่น พื้นหลังลายตารางที่ฝังอยู่ในภาพ การรวมชิ้นส่วนที่อยู่ใกล้กัน (เช่น ตัวละครกับเงา) ให้เป็นไฟล์เดียวกัน แต่ไม่ให้ตัวละครสองตัวที่ยืนใกล้กันถูกตัดรวมเป็นภาพเดียว และที่สำคัญคือต้องรักษา Pixel Art ให้คมชัดแบบ 1:1 โดยไม่มีการเบลอหรือ Resize

ก่อนเริ่มเขียนโค้ด ให้ติดตั้งไลบรารีที่จำเป็นก่อน:

pip install pillow opencv-python numpy

ขั้นตอนที่ 1: โหลดภาพและกำจัดพื้นหลังลายตาราง

พื้นหลังลายตารางสีขาว-เทา (Checkerboard) มักใช้บ่งบอกความโปร่งใสในโปรแกรมตกแต่งภาพ เราจะใช้ OpenCV ในการตรวจจับสีเหล่านี้และแปลงเป็น Alpha Channel แบบโปร่งใสจริง

import cv2
import numpy as np
from PIL import Image
import os
import json
import zipfile

def remove_checkerboard(img_path):
    # โหลดภาพโดยไม่เปลี่ยนแปลงค่าพิกเซลเดิม
    img = cv2.imread(img_path, cv2.IMREAD_UNCHANGED)
    
    # แปลงเป็น RGB (หากภาพมี Alpha อยู่แล้ว ให้จัดการให้เรียบร้อยก่อน)
    if img.shape[2] == 4:
        rgb = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
    else:
        rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    
    # กำหนดช่วงสีของพื้นหลังลายตาราง (ปรับค่าสีตาม Sprite Sheet ของคุณ)
    lower_white = np.array([240, 240, 240], dtype=np.uint8)
    upper_white = np.array([255, 255, 255], dtype=np.uint8)
    
    lower_gray = np.array([190, 190, 190], dtype=np.uint8)
    upper_gray = np.array([210, 210, 210], dtype=np.uint8)
    
    # สร้าง Mask สำหรับพื้นหลัง
    mask_white = cv2.inRange(rgb, lower_white, upper_white)
    mask_gray = cv2.inRange(rgb, lower_gray, upper_gray)
    mask = cv2.bitwise_or(mask_white, mask_gray)
    
    # สร้าง Alpha Channel (255 = ทึบ, 0 = โปร่งใส)
    alpha = np.ones(rgb.shape[:2], dtype=np.uint8) * 255
    alpha[mask > 0] = 0
    
    # รวม RGB กับ Alpha
    rgba = np.dstack((rgb[:, :, :3], alpha))
    return rgba

ฟังก์ชันนี้จะคืนค่าเป็น Numpy Array ที่มี 4 ช่องสี (RGBA) โดยพื้นที่ที่เป็นลายตารางจะถูกตั้งค่า Alpha เป็น 0 อย่างสมบูรณ์

ขั้นตอนที่ 2: ตรวจจับ Sprite และรวมชิ้นส่วนใกล้กัน

เมื่อได้ภาพโปร่งใสแล้ว เราจะใช้ cv2.findContours เพื่อหาขอบเขตของวัตถุ จากนั้นต้องมีอัลกอริทึมในการรวม Bounding Box ที่อยู่ใกล้กัน เพื่อให้ตัวละครและเงาหรืออาวุธที่แยกกันอยู่กลายเป็นไฟล์เดียวกัน แต่ต้องไม่ใกล้จนรวมกับตัวละครตัวถัดไป

def detect_and_merge_sprites(rgba, merge_distance=10):
    alpha = rgba[:, :, 3]
    
    # หา Contours
    contours, _ = cv2.findContours(alpha, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    
    boxes = []
    for c in contours:
        x, y, w, h = cv2.boundingRect(c)
        # ข้าม噪聲 (Noise) ที่เล็กเกินไป
        if w * h > 10: 
            boxes.append([x, y, x + w, y + h])
            
    # ฟังก์ชันสำหรับตรวจสอบว่า Box ใกล้กันหรือไม่
    def is_close(box1, box2, dist):
        # คำนวณระยะห่างระหว่างขอบของ Box
        # (โค้ดส่วนนี้ต้องคำนวณระยะทั้งแกน X และ Y)
        # หากมีการทับซ้อนหรือระยะน้อยกว่า dist ให้ถือว่าใกล้กัน
        return True # (ตัวอย่างสั้น)

    # ลูปรวม Box ที่ใกล้กัน
    merged = True
    while merged:
        merged = False
        new_boxes = []
        used = [False] * len(boxes)
        for i in range(len(boxes)):
            if used[i]:
                continue
            current = boxes[i]
            for j in range(i + 1, len(boxes)):
                if used[j]:
                    continue
                if is_close(current, boxes[j], merge_distance):
                    # ขยายขอบเขตของ current ให้ครอบคลุม boxes[j]
                    current[0] = min(current[0], boxes[j][0])
                    current[1] = min(current[1], boxes[j][1])
                    current[2] = max(current[2], boxes[j][2])
                    current[3] = max(current[3], boxes[j][3])
                    used[j] = True
                    merged = True
            new_boxes.append(current)
            used[i] = True
        boxes = new_boxes
        
    return boxes

การกำหนด merge_distance สำคัญมาก หากตั้งค่าสูงเกินไป ตัวละครสองตัวอาจถูกรวมเป็นภาพเดียว หากตั้งต่ำเกินไป เงาอาจถูกแยกออกจากตัวละคร

ขั้นตอนที่ 3: Crop ภาพและจัดหมวดหมู่

หลังจากได้ Bounding Box ที่ถูกต้องแล้ว เราจะทำการ Crop ภาพโดยเพิ่ม Padding 6px รอบวัตถุเพื่อไม่ให้ขอบชิดเกินไป จากนั้นจัดหมวดหมู่ (ซึ่งในทางปฏิบัติอาจต้องใช้ Machine Learning หรือการกำหนดเองด้วยมือผ่าน HTML Editor ในขั้นตอนถัดไป)

def crop_and_save(rgba, boxes, output_dir):
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)
        
    metadata = []
    # สมมติว่าเรามีฟังก์ชันจัดหมวดหมู่ (อาจจะเป็นการวนลูปหรือกำหนดด้วยมือ)
    categories = ['player', 'npc', 'enemies', 'bosses', 'props']
    
    for i, box in enumerate(boxes):
        x1, y1, x2, y2 = box
        # เพิ่ม Padding 6px
        pad = 6
        x1 = max(0, x1 - pad)
        y1 = max(0, y1 - pad)
        x2 = min(rgba.shape[1], x2 + pad)
        y2 = min(rgba.shape[0], y2 + pad)
        
        sprite = rgba[y1:y2, x1:x2]
        
        # แปลงจาก Numpy เป็น PIL Image (ใช้ NEAREST เพื่อรักษา Pixel Art)
        pil_img = Image.fromarray(sprite, 'RGBA')
        
        # ตั้งชื่อไฟล์ (ที่นี่สมมติว่าทุกตัวเป็น player ไปก่อน)
        cat = 'player' 
        filename = f"{cat}_{i+1:03d}.png"
        filepath = os.path.join(output_dir, filename)
        pil_img.save(filepath)
        
        metadata.append({
            'filename': filename,
            'category': cat,
            'x': x1,
            'y': y1,
            'width': x2 - x1,
            'height': y2 - y1
        })
        
    return metadata

สังเกตว่าเราใช้ Image.fromarray และบันทึกเป็น PNG ซึ่งจะรักษา Pixel 1:1 ไว้อย่างแน่นอน ไม่มีการเบลอหรือ Smooth ใดๆ เกิดขึ้น

ขั้นตอนที่ 4: สร้าง Metadata, Contact Sheet และบีบอัดไฟล์

เมื่อได้ไฟล์ภาพทั้งหมดแล้ว เราต้องสร้างไฟล์ metadata.json เพื่อบันทึกข้อมูลพิกัด สร้าง Contact Sheet (ภาพรวมเพื่อตรวจสอบ) และบีบอัดทุกอย่างเป็น game-assets.zip

def create_contact_sheet(output_dir, metadata):
    # สร้างภาพ Contact Sheet ขนาดเล็กเพื่อตรวจสอบผลลัพธ์
    # (โค้ดส่วนนี้ใช้ Pillow ในการต่อภาพเล็กเป็นตารางใหญ่)
    pass

def export_assets(output_dir, metadata):
    # บันทึก metadata.json
    with open(os.path.join(output_dir, 'metadata.json'), 'w') as f:
        json.dump(metadata, f, indent=4)
        
    # บีบอัดเป็น zip
    with zipfile.ZipFile('game-assets.zip', 'w') as zipf:
        for root, dirs, files in os.walk(output_dir):
            for file in files:
                zipf.write(os.path.join(root, file), file)

ขั้นตอนที่ 5: สร้าง HTML Editor สำหรับปรับกรอบ Crop

บางครั้งการตรวจจับอัตโนมัติอาจไม่แม่นยำ 100% โดยเฉพาะเรื่องการจัดหมวดหมู่ (Category) การสร้างหน้า HTML ที่โหลดภาพ Sprite Sheet ต้นฉบับและวาดกรอบ Bounding Box ที่ตรวจจับได้ทับลงไป จะช่วยให้ผู้ใช้สามารถลากเมาส์เพื่อปรับขนาดกรอบหรือเปลี่ยนหมวดหมู่ก่อนกด Export ได้

<!-- ตัวอย่างโครงสร้าง HTML Editor -->
<!DOCTYPE html>
<html>
<head>
    <title>Sprite Sheet Editor</title>
</head>
<body>
    <canvas id="canvas" width="800" height="600"></canvas>
    <script>
        // โหลดภาพและวาดกรอบ (ข้อมูลกรอบดึงจาก metadata.json ที่สร้างไว้)
        // ใช้ Mouse Event เพื่อลากและปรับขนาดกรอบ
        // ปุ่ม Export จะส่งข้อมูลกรอบใหม่กลับไปยัง Python (ผ่าน Flask หรืออ่านไฟล์ JSON ใหม่)
    </script>
</body>
</html>

ในระบบจริง คุณอาจใช้ Flask หรือ FastAPI รันเป็น Local Server เพื่อให้ HTML ส่งข้อมูลกลับมาประมวลผลที่ Python ได้สะดวก

การตรวจสอบคุณภาพ (Quality Check)

ก่อนส่งมอบงาน สคริปต์ของเราต้องผ่านเกณฑ์เหล่านี้:

  • ตรวจสอบว่าทุกไฟล์เป็น PNG และมี Alpha Channel
  • แตะพิกเซลพื้นหลังเพื่อยืนยันว่า Alpha เป็น 0
  • ตรวจสอบขนาดภาพว่าไม่มีการขยายหรือย่อ (เทียบกับขนาด Box ต้นฉบับ + Padding)
  • ตรวจสอบว่าไม่มีไฟล์ภาพที่มีขนาด 0x0

บทสรุปและขั้นตอนต่อไป

การเขียนสคริปต์ Python เพื่อแยก Sprite Sheet ไม่เพียงแต่ประหยัดเวลา แต่ยังช่วยให้กระบวนการผลิตเกมมีความเป็นระบบและลดความผิดพลาดจากมนุษย์ คุณสามารถนำโค้ดนี้ไปต่อยอด เช่น การใช้ Machine Learning จำแนกประเภทตัวละครอัตโนมัติ หรือการทำ Animation Slicing ที่ซับซ้อนมากขึ้น

ลองนำสคริปต์นี้ไปรันกับ Sprite Sheet ของคุณดู และอย่าลืมปรับค่า merge_distance และช่วงสีของพื้นหลังให้เข้ากับงานของคุณ หากชอบบทความประเภทนี้ อย่าลืมติดตามและแชร์ให้เพื่อนนักพัฒนาคนอื่นๆ ด้วยนะครับ!

บทความนี้เป็นอย่างไร?

ยังไม่มีความคิดเห็น — มาเป็นคนแรกกันเถอะ!