แยก 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 และช่วงสีของพื้นหลังให้เข้ากับงานของคุณ หากชอบบทความประเภทนี้ อย่าลืมติดตามและแชร์ให้เพื่อนนักพัฒนาคนอื่นๆ ด้วยนะครับ!
ความคิดเห็น (0)
ยังไม่มีความคิดเห็น — มาเป็นคนแรกกันเถอะ!