เตรียม Dataset ป้ายทะเบียนไทย Fine-tune AI LPR | PWD Vision Works
ป้ายทะเบียนไทยมีสระ วรรณยุกต์ และ 77 จังหวัดที่ต้องครอบคลุม การเก็บภาพจริงให้ครบสมดุลแทบเป็นไปไม่ได้ นี่คือวิธีที่เราแก้ปัญหานี้ด้วยการประกอบกลีฟสังเคราะห์
author: PWD Vision Works date: 2026-08-16
สอน Edge AI ให้อ่านป้ายทะเบียนไทย: เรื่องเล่าจากการเตรียม Dataset
ตอนที่เราปล่อยชุดอุปกรณ์ Edge AI LPR (License Plate Recognition) เวอร์ชั่นแรก เรื่องของ Hardware ค่อนข้างลงตัว คือ Raspberry Pi 5 จับคู่กับ Hailo AI Accelerator รัน YOLOv8 สำหรับตรวจจับตำแหน่งป้ายทะเบียน พอมาเวอร์ชั่นที่สอง เราย้ายไปใช้ NVIDIA Jetson Orin Nano และเปลี่ยน Model เป็น YOLOv11 nano ทั้งสองเวอร์ชั่นตรวจจับตำแหน่งป้ายได้ดีมาก กรอบ Bounding Box แม่นยำ Inference แบบ Real-time ไม่มีปัญหาในจุดนี้เลย
ปัญหาจริง ๆ อยู่อีกชั้นหนึ่งลึกลงไป คือตัว Detector หาป้ายทะเบียนใน Frame เจอ แต่ "อ่าน" ตัวอักษรบนป้ายไม่ได้ เพราะ Model OCR ที่ใช้อ่านป้ายทะเบียน (generic model) ส่วนใหญ่ถูกฝึกมาด้วยป้ายทะเบียนอักษรละติน ป้ายทะเบียนไทยไม่ได้ต่างแค่ตัวอักษร แต่โครงสร้างการวางตัวอักษรก็ต่างด้วย บรรทัดบนเป็นพยัญชนะและตัวเลข บรรทัดล่างเป็นชื่อจังหวัดภาษาไทย ซึ่งชื่อจังหวัดหลายชื่อมีสระนำหน้าและวรรณยุกต์ซ้อนกันในแบบที่ Model ไม่เคยเห็นในข้อมูลฝึกสอนมาก่อนเลย
คำตอบที่ชัดเจนคือต้อง Fine-tune Model ใหม่ให้อ่านป้ายไทยได้ แต่โจทย์จริงของโปรเจกต์นี้กลับไม่ใช่การเขียนโค้ด Fine-tune — มันคือการหา Dataset ที่ดีพอสำหรับใช้ Fine-tune ต่างหาก
คอขวดที่แท้จริง: ความครอบคลุม ไม่ใช่โค้ด
ประเทศไทยมี 77 จังหวัด แต่ละจังหวัดมีชื่อเต็มภาษาไทยที่ต้องปรากฏถูกต้องบนป้าย (ต้องเป็น "กรุงเทพมหานคร" ไม่ใช่ "กรุงเทพ") Dataset ที่จะใช้ฝึกสอนได้จริงต้องมีพยัญชนะครบทุกตัว ตัวเลขครบทุกตัว และชื่อจังหวัดครบทุกจังหวัด ในปริมาณที่มากพอ และที่สำคัญคือต้อง "สมดุล" พอที่ Model จะไม่ไปจำแค่รูปแบบป้ายจากจังหวัดที่เราถ่ายรูปมาเยอะที่สุด
ลองดูสภาพข้อมูลจริงในช่วงแรกของเรา ซึ่งดึงมาจากสรุปผลของเครื่องมือ Annotate เอง:
Session complete: 241 saved, 10 skipped
Total crops in lp_crops: 540
Province coverage (22/77 provinces):
152 เชียงใหม่ ████████████████████████████████████████
35 กรุงเทพมหานคร ███████████████████████████████████
20 แม่ฮ่องสอน ████████████████████
9 เชียงราย █████████
5 พะเยา █████
...
1 กำแพงเพชร █
⚠️ Missing 55 provinces
รูปป้ายทะเบียนจริง 540 รูป แต่มีถึง 55 จาก 77 จังหวัดที่ไม่มีตัวอย่างเลยสักรูปเดียว นี่ไม่ใช่ปัญหาการติดป้ายกำกับ (Labeling) แต่เป็นปัญหาเชิงภูมิศาสตร์ล้วน ๆ — ไม่มีทางที่เราจะขับรถไปถ่ายป้ายทะเบียนในลานจอดรถที่ระนองหรือสิงห์บุรีให้ได้จำนวนพอ ๆ กับเชียงใหม่ในเวลาอันสั้น แทนที่จะไล่ตามการเดินทางเก็บภาพเพิ่มเรื่อย ๆ เราจึงเขียนเครื่องมือขึ้นมาให้ภาพที่มีอยู่แล้ว "ทำงานหนักขึ้น" และสังเคราะห์ป้ายที่เก็บภาพจริงไม่ทันเสียเอง
ขั้นที่ 1 — Annotate ครั้งเดียว ฝัง Ground Truth ไว้ในชื่อไฟล์
crop_lp.py เป็นเครื่องมือ Interactive ง่าย ๆ คือลากกรอบรอบป้ายทะเบียนในรูป พิมพ์ข้อความบนป้าย แล้วโปรแกรมจะบันทึกภาพ Crop เป็นชื่อไฟล์รูปแบบ {พยัญชนะ}_{ตัวเลข}_{จังหวัด}_{id:06d}.jpg เช่น กข_1234_เชียงใหม่_000001.jpg ข้อมูล Ground Truth จึงฝังอยู่ในชื่อไฟล์เอง ทำให้ไม่มีทางที่ไฟล์ Label แยกต่างหากจะหลุด Sync กับรูปภาพ
ชื่อจังหวัดจะถูกตรวจสอบกับรายชื่อ 77 จังหวัดทางการทันทีตอนกรอกข้อมูล พร้อม Fuzzy Matching สำหรับชื่อย่อที่คนมักพิมพ์ผิด:
if province not in PROVINCE_SET:
close = [p for p in PROVINCES if province in p or p in province]
if len(close) == 1:
print(f" → Province '{province}' matched to '{close[0]}'")
province = close[0]
elif len(close) > 1:
raise ValueError(f"Province '{province}' is ambiguous. Did you mean one of: {close}")
เครื่องมือยังพิมพ์กราฟแท่งสรุปความครอบคลุมทุกครั้งที่จบ Session (แบบที่เห็นด้านบน) ทำให้เห็นช่องโหว่ของ Dataset ได้ทันที ไม่ต้องรอไปเจอตอน Train Model แล้วค่อยงงว่าทำไม Accuracy ตกในบางจังหวัด

ขั้นที่ 2 — แตกโครงสร้างอักษรไทยให้เป็น "ช่องกลีฟ" (Glyph Slot)
ก่อนจะ Crop ตัวอักษรทีละตัวได้ เราต้องตอบคำถามพื้นฐานกว่านั้นก่อนว่า อะไรคือ "หนึ่งตัวอักษร" บนป้ายทะเบียนไทยกันแน่ Unicode ภาษาไทยเก็บข้อความตามลำดับการอ่านซ้าย-ไปขวาอยู่แล้ว — สระนำหน้าอย่าง เ แ โ ใ จะถูกเก็บ ก่อน พยัญชนะที่มันประกอบด้วยเสมอ ตรงกับตำแหน่งที่วาดจริง — แต่วรรณยุกต์และสระบางตัวก็ซ้อนอยู่บน ล่าง หรือข้าง ๆ พยัญชนะหลัก ถ้าเข้าใจง่าย ๆ ว่า "หนึ่งอักขระ Unicode เท่ากับหนึ่งภาพ Crop" จะพังทันทีเมื่อเจอวรรณยุกต์ซ้อน
thai_layout.py แก้ปัญหานี้ครั้งเดียวจบ เขียนเป็น Logic ล้วน ๆ ไม่พึ่งพา Library รูปภาพ ทำให้ Test แยกได้อิสระ:
POSITION = {
'ะ': 'FOLLOWING', 'ั': 'ABOVE', 'า': 'FOLLOWING', 'ำ': 'FOLLOWING',
'ิ': 'ABOVE', 'ี': 'ABOVE', 'ึ': 'ABOVE', 'ุ': 'BELOW', 'ู': 'BELOW',
'เ': 'LEADING', 'แ': 'LEADING', 'โ': 'LEADING', 'ใ': 'LEADING',
'็': 'ABOVE', '่': 'TONE', '้': 'TONE', '์': 'ABOVE',
}
สคริปต์นี้รัน Regression Test ในตัวเองทุกครั้งที่เรียกใช้ตรง ๆ คือแตกโครงสร้างชื่อจังหวัดทั้ง 77 จังหวัด แล้วประกอบกลับให้ตรงกับข้อความต้นฉบับทุกตัวอักษร (Round-trip) ถ้าจังหวัดไหนแตกไม่ผ่านหรือประกอบกลับไม่ตรง สคริปต์จะแจ้ง Error ทันที ตัวอย่างเช่น "เชียงใหม่" จะถูกแตกเป็นช่องแบบ เ(LEADING) ช(BASE), ี(ABOVE) ย(BASE) และต่อไปเรื่อย ๆ — แต่ละช่องคือหนึ่งตำแหน่งแนวนอนที่ตัวประกอบภาพ (Compositor) จะนำกลีฟไปวางได้อย่างอิสระในขั้นตอนถัดไป


ขั้นที่ 3 — สร้างคลังกลีฟ (Glyph Bank) จากภาพที่มีอยู่แล้ว
เพราะ Ground Truth ฝังอยู่ในชื่อไฟล์ lp_crops/ อยู่แล้ว crop_glyphs.py จึงไม่ต้อง Label ใหม่เลย แค่ไล่ดูภาพ Crop จริงทีละภาพ แล้วให้เราลากกรอบตัวอักษรแต่ละตัวที่โปรแกรมรู้อยู่แล้วว่าคือตัวอะไร ภาพแต่ละกรอบจะถูกบันทึกลง glyph_bank/<ตัวอักษร>/ พร้อมบันทึกตำแหน่งเรขาคณิตไว้เป็น Template "ป้ายต้นแบบ" (Donor Layout) สำหรับใช้ซ้ำภายหลัง
มีจุดออกแบบสองจุดที่ทำให้งานนี้ทำคนเดียวไหวจริง ๆ แม้จะต้องไล่ Crop เป็นหลักร้อยภาพ:
จุดแรก ภาพเบลอจะถูกเตือนอัตโนมัติก่อนเสียเวลาลากกรอบป้ายที่อ่านไม่ออก:
def sharpness_score(img_bgr) -> float:
"""Laplacian variance — a quick, standard blur heuristic (higher = sharper)."""
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
return cv2.Laplacian(gray, cv2.CV_64F).var()
BLUR_THRESHOLD = 40.0
จุดที่สอง เมื่อตัวอักษรไหนมีตัวอย่างเพียงพอแล้ว เครื่องมือจะหยุดถามเราเรื่องตัวอักษรนั้นโดยอัตโนมัติ เพื่อให้เวลาทั้งหมดไปโฟกัสที่ตัวอักษรหายากแทนที่จะไป Crop เลข "1" เป็นครั้งที่สี่สิบ:
if max_per_char is not None and out_dir is not None and count_for(ch) >= max_per_char:
print(f" Glyph {i+1}/{len(slots)}: '{ch}' — already have "
f"{count_for(ch)}/{max_per_char}, auto-skipping")
results.append((ch, pos, None))
continue


ขั้นที่ 4 — ประกอบป้ายทะเบียนสังเคราะห์จากกลีฟจริง
นี่คือขั้นตอนที่ปิดช่องว่างความครอบคลุมได้จริง compose_plate.py จะเลือก "ป้ายต้นแบบ" (Donor) — ภาพที่ผ่านการ Crop กลีฟไปแล้ว — นำพื้นหลัง แสง และเรขาคณิตของแถวตัวอักษรจากป้ายนั้นมาใช้ซ้ำ ลบตัวอักษรเดิมออก แล้ววางกลีฟจากคลังลงไปแทนที่ เพื่อสะกดเป็นชื่อจังหวัดและเลขทะเบียนใหม่ที่เราต้องการ:
def compose_plate(donor_stem, consonants, digits, province, glyph_bank_dir,
src_dir, out_dir, id_num, rng=None):
layout = load_layout(glyph_bank_dir, donor_stem)
top_region = row_bbox(layout, 'top')
bottom_region = row_bbox(layout, 'bottom')
if top_region is None or bottom_region is None:
return None, set()
missing = set()
compose_registration(img, top_region, consonants + digits, glyph_bank_dir, rng, missing)
compose_province(img, bottom_region, province, glyph_bank_dir, rng, missing)
...
การวางสระและวรรณยุกต์ใช้ Logic ช่องกลีฟชุดเดียวกับ thai_layout.py — สระนำหน้าจะได้กรอบแคบ ๆ ทางซ้ายของพยัญชนะหลัก วรรณยุกต์บน/ล่างจะเลื่อนแนวตั้งตามสัดส่วนความสูงของกลีฟหลัก และถ้ากลีฟที่ต้องใช้ยังไม่มีในคลัง โปรแกรมจะบันทึกไว้เป็นรายการ missing และรายงานออกมา แทนที่จะปล่อยให้ช่องว่างเงียบ ๆ โดยไม่บอกใคร

ขั้นที่ 5 — ให้แผนงานขับเคลื่อนตัวเอง
ชิ้นสุดท้าย augment_missing_provinces.py ทำหน้าที่เป็นตัวควบคุมภาพรวม อ่านสถานะความครอบคลุมของแต่ละจังหวัดในปัจจุบัน ตรวจสอบว่าจังหวัดไหน "สร้างได้จริง" ตามกลีฟที่มีอยู่ในคลังตอนนี้ แล้วพิมพ์แผนการสร้างออกมาก่อนที่จะเขียนไฟล์แม้แต่ไฟล์เดียว:
plan = {}
skipped = {}
for p in PROVINCES:
have = real_stats.get(p, 0)
need = max(0, args.target - have)
if need == 0:
continue
coverable, missing = province_coverable(p, have_chars)
if not coverable:
skipped[p] = missing
continue
plan[p] = need
รันด้วย --dry-run ก่อนเสมอ โปรแกรมจะบอกชัดเจนว่าจะสร้างภาพสังเคราะห์กี่ภาพต่อจังหวัด และจังหวัดไหนที่ยัง "สร้างไม่ได้" เพราะกลีฟบางตัวยังขาดอยู่ในคลัง — เป็นสัญญาณตรง ๆ ว่าต้องไปถ่ายรูปหรือ Crop กลีฟตัวไหนเพิ่ม แทนที่จะเดาสุ่ม
สิ่งที่ได้กลับมาจริง ๆ
จากที่มีข้อมูลครอบคลุมเพียง 22 จาก 77 จังหวัด และสัดส่วนความเหลื่อมล้ำระหว่างจังหวัดที่ถ่ายรูปมากที่สุดกับน้อยที่สุดสูงถึง 280:1 มาเป็นครอบคลุมครบทั้ง 77 จังหวัด โดยไม่ต้องออกเดินทางถ่ายรูปเพิ่มอีกสักครั้งเดียว คือความต่างระหว่าง Dataset ที่ Overfit กับป้ายเชียงใหม่ กับ Dataset ที่มีโอกาส Generalize ครอบคลุมทั่วประเทศได้จริง วิธีนี้ไม่ได้มาแทนที่การถ่ายภาพจริง — ทุกกลีฟในคลังยังสืบย้อนกลับไปถึงป้ายทะเบียนจริงที่มีคนถ่ายมาเสมอ — แต่มันคือวิธีทำให้ Dataset ขนาดเล็กที่ซื่อสัตย์กับข้อมูลจริง ยืดขยายจนพอสำหรับการฝึกสอน Model ได้
บทเรียนที่ใหญ่กว่านั้นสำหรับใครก็ตามที่กำลังสร้างผลิตภัณฑ์ Edge AI บนภาษาหรือ Domain ที่มีข้อมูลจำกัด คือ Architecture ของ Model แทบไม่เคยเป็นคอขวดจริง คอขวดที่แท้จริงเกือบทุกครั้งคือการหา Dataset ที่ครบถ้วน สมดุล และติด Label อย่างซื่อสัตย์ — และสำหรับโจทย์นี้ เครื่องมือที่ออกแบบมาเฉพาะทางชนะการเก็บข้อมูลแบบทุ่มแรงงานล้วน ๆ เสมอ
บทความถัดไปในซีรีส์นี้ เราจะพาไปดูขั้นตอน Fine-tune ตัว Recognition Head บน Dataset ชุดนี้จริง ๆ และผล Accuracy ที่เปลี่ยนไปเมื่อความครอบคลุมชื่อจังหวัดไม่ใช่ปัจจัยจำกัดอีกต่อไปครับ
PWD Vision Works พัฒนาโซลูชัน Edge AI และ Computer Vision สำหรับการใช้งานในตลาดไทย ตั้งแต่ Raspberry Pi 5 + Hailo ไปจนถึง NVIDIA Jetson Orin Nano ติดตามซีรีส์นี้ต่อได้ที่ bs4u-tech.com เพื่อดูกระบวนการ Fine-tune LPRNet แบบเต็มขั้นตอนครับ
คำถามที่พบบ่อย
ทำไม Generic YOLO Model ถึงอ่านป้ายทะเบียนไทยไม่ได้?
เพราะ Model OCR ทั่วไปถูกฝึกด้วยป้ายทะเบียนอักษรละติน ขณะที่ป้ายไทยมีโครงสร้างต่างกัน คือพยัญชนะ-ตัวเลขอยู่บรรทัดบน ชื่อจังหวัดภาษาไทยอยู่บรรทัดล่าง ซึ่งมีสระนำหน้าและวรรณยุกต์ซ้อนที่ Model ไม่เคยเห็นมาก่อน จึงต้อง Fine-tune ด้วย Dataset เฉพาะทาง
ตรียม Dataset ป้ายทะเบียนไทยสำหรับ Fine-tune LPR Model ต้องมีอะไรบ้าง?
ต้องมีภาพป้ายที่ครอบคลุมพยัญชนะและตัวเลขครบทุกตัว รวมถึงชื่อเต็มของทั้ง 77 จังหวัด ในปริมาณที่เพียงพอและสมดุลกัน เพื่อไม่ให้ Model overfit กับจังหวัดที่มีภาพเยอะที่สุด
Glyph Compositing คืออะไร และช่วยแก้ปัญหา Dataset ไม่สมดุลอย่างไร?
คือการนำภาพตัวอักษรจริงที่ Crop จากป้ายทะเบียนจริงมาประกอบใหม่บนพื้นหลังป้ายต้นแบบ (Donor) เพื่อสร้างป้ายทะเบียนสังเคราะห์สำหรับจังหวัดที่ยังไม่มีภาพถ่ายจริงเพียงพอ โดยไม่ต้องเดินทางไปถ่ายภาพเพิ่ม
จัดการโครงสร้างสระและวรรณยุกต์ภาษาไทยตอนตัด Glyph อย่างไร?
ใช้การแตกข้อความเป็น 'ช่องกลีฟ' (Glyph Slot) โดยจัดกลุ่มสระนำหน้า (เ แ โ ใ) และวรรณยุกต์/สระบน-ล่างให้อยู่กับพยัญชนะหลักที่มันประกอบด้วย แล้วบันทึกตำแหน่งสัมพัทธ์ไว้ใช้ตอนวางภาพกลีฟใหม่
Dataset ป้ายทะเบียนไทยจริงของโปรเจกต์นี้มีขนาดเท่าไหร่?
ช่วงเริ่มต้นมีภาพป้ายทะเบียนจริง 540 ภาพ ครอบคลุมเพียง 22 จาก 77 จังหวัด ก่อนใช้เทคนิค Glyph Compositing สร้างภาพสังเคราะห์เพิ่มเติมจนครอบคลุมครบทุกจังหวัด
Hardware ที่ใช้กับระบบ Edge AI LPR ของ PWD Vision Works มีอะไรบ้าง?
เวอร์ชั่นแรกใช้ Raspberry Pi 5 ร่วมกับ Hailo AI Accelerator รัน YOLOv8 ส่วนเวอร์ชั่นที่สองอัปเกรดเป็น NVIDIA Jetson Orin Nano รัน YOLOv11 nano สำหรับตรวจจับตำแหน่งป้ายทะเบียน