เตรียม 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 ตกในบางจังหวัด

crop_lp_roi.webp

ขั้นที่ 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) จะนำกลีฟไปวางได้อย่างอิสระในขั้นตอนถัดไป

thai-license-plate-dataset-edge-ai-lpr-crop-gylphs-leading.webp

thai-license-plate-dataset-edge-ai-lpr-crop-gylphs-below.webp

ขั้นที่ 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

crop_glyphs_base.webp

thai-license-plate-dataset-edge-ai-lpr-crop-gylphs-bank.webp

ขั้นที่ 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 และรายงานออกมา แทนที่จะปล่อยให้ช่องว่างเงียบ ๆ โดยไม่บอกใคร

thai-license-plate-dataset-edge-ai-lpr-skip-provice.webp

ขั้นที่ 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 สำหรับตรวจจับตำแหน่งป้ายทะเบียน