แปลง pixel เป็นพิกัดจริง: เมื่อ vendor ย่อรูปแล้ว grid ของคุณเพี้ยนทั้งแผนที่
ระบบที่อ่านค่าจากภาพแล้ว map pixel ไปเป็นพิกัดจริง เปราะบางกว่าที่คิด — วันที่ upstream ย่อรูปแบบเงียบๆ ค่าที่ได้กลายเป็นศูนย์ทั้งแผนที่โดยไม่มี error สักตัว บทเรียนเรื่อง calibration ที่ผูกกับ resolution และวิธีแก้ให้ generic
อ่าน ~8 นาที
Pixel→world mapping ทำงานยังไง และทำไมมันเปราะกับขนาดภาพ
ระบบจำนวนไม่น้อยต้องอ่านค่าออกมาจาก "ภาพ" แทนที่จะได้ตัวเลขดิบมาตรงๆ ผมเจอเคสนี้บ่อยในงาน geo — เรดาร์ฝน แผนที่ความร้อน ภาพถ่ายดาวเทียม ที่หน่วยงานต้นทางเสิร์ฟมาเป็นรูป PNG/JPG แล้วเราต้องแปลงสีของแต่ละพิกเซลกลับเป็นค่าจริง (เช่น ความเข้มฝนหน่วย dBZ) พร้อมกับรู้ว่าพิกเซลนั้น อยู่ตรงไหนบนโลกจริง (lat/lng หรือ UTM)
หัวใจของการแปลงตำแหน่งคือ calibration table — ตารางที่จับคู่ว่า "cell ของกริดในโลกจริงตรงนี้ = pixel (X, Y) ตรงนั้นในภาพ" ตารางนี้มักถูกสร้างครั้งเดียวด้วยมือ โดยเอา landmark ที่รู้พิกัดแน่ๆ (แม่น้ำ ชายแดนจังหวัด จุดตัดถนน) ไปเล็งกับพิกเซลในภาพตัวอย่างหนึ่งภาพ
และตรงนี้เองคือจุดเปราะที่คนมองข้าม: ค่า pixel (X, Y) ในตารางนั้นผูกติดกับ "ขนาดภาพ" ที่ใช้ตอน calibrate โดยปริยาย ถ้าภาพตอน calibrate คือ 1920×1600 พิกัด Pix X = 1567 จะหมายถึงจุดที่อยู่ค่อนไปทางขวาของภาพ แต่ถ้าวันหนึ่งภาพที่ไหลเข้ามากลายเป็น 800×800 เลข 1567 นั้น หลุดออกนอกภาพไปแล้ว ทั้งที่ไม่มีใครแก้ตารางสักบรรทัด calibration table ที่ไม่บันทึกว่า "ฉัน calibrate ที่ resolution เท่าไหร่" คือระเบิดเวลา
เคสจริง: upstream ย่อรูป 1920×1600 → 800×800 แบบ non-uniform
ผมดูแล pipeline ที่ดึงภาพเรดาร์จากหน่วยงานอากาศแห่งหนึ่งมาวิเคราะห์ฝน ระบบทำงานดีมาเป็นปี จนวันหนึ่งมีคนทักมาว่า "พื้นที่นี้ฝนตกจริงนะ แต่แอปไม่ขึ้นเตือน" ผมเช็ค log ไม่มี error ครับ capture ก็ได้ภาพสดเข้ามาปกติ ภาพมีสีฝนชัดเจนด้วยซ้ำ แต่ผลวิเคราะห์คือ ฝน 0% ทั้งจังหวัด
สาเหตุคือต้นทางเปลี่ยนภาพจาก 1920×1600 เหลือ 800×800 แบบเงียบๆ และประเด็นสำคัญคือมันเป็นการย่อ แบบ non-uniform — แกน X ถูกหารด้วย ~2.4 ส่วนแกน Y ถูกหารด้วย 2.0 (aspect ratio เปลี่ยนจาก 1.2 เป็น 1.0) นี่ไม่ใช่การ scale สัดส่วนเดิม แต่เป็นการบีบภาพให้จัตุรัส
calibration table ของผมมีพิกัดที่ calibrate จากภาพ 1920×1600 ค่า Pix X/Y จึงพุ่งไปถึง 1567–1751 พอเอาพิกัดพวกนี้ไปอ่านบนภาพ 800×800 โค้ด extract region ก็ทำสิ่งที่ "ปลอดภัย" ที่สุด — มัน clamp ค่าที่เกินขอบให้เหลือ 799
# สิ่งที่เกิดขึ้นตอน grid อ่านภาพผิดขนาด (แนวคิด)
# พิกัดจาก table calibrate ที่ 1920x1600 แต่ภาพจริง = 800x800
px = min(pix_x, img_w - 1) # 1567 -> clamp เหลือ 799
py = min(pix_y, img_h - 1) # 1751 -> clamp เหลือ 799
region = image[py-half:py+half, px-half:px+half]
# ทุก cell ที่พิกัดเกินขอบ ถูกดันไปกองมุมขวาล่างหมด
# มุมนั้นเป็นพื้นที่นอกแผนที่ = สีดำล้วน -> pixel_count 0, color (0,0,0)
ผลคือแทบทุก cell ของกริดถูกดันไปกองที่มุมขวาล่างของภาพ ซึ่งเป็นพื้นที่ว่างสีดำนอกแผนที่ อ่านออกมาได้ pixel_count = 0, color = (0,0,0) ทุก cell แปลงเป็นฝน 0 ทั้งกระดาน
อาการหลอกตา: ระบบไม่ error แต่ผลลัพธ์ผิด
บั๊กประเภทนี้อันตรายกว่า crash หลายเท่า เพราะทุกชั้นของระบบ "ทำงานถูกตามสัญญาของตัวเอง":
- ตัว capture ทำงานถูก — มันได้ภาพสดที่มีฝนจริงมาเก็บลง disk
- โค้ด clamp ทำงานถูก — มันกันไม่ให้ index หลุดขอบ array (ป้องกัน IndexError อย่างที่ควร)
- ตัววิเคราะห์สีทำงานถูก — พิกเซลสีดำแปลว่าไม่มีฝน มันก็รายงาน 0 อย่างซื่อสัตย์
- API ตอบ 200 หน้าจอ render ปกติ ไม่มีเลขแดง ไม่มี stack trace
ไม่มีใครโกหก แต่ผลรวมคือคำตอบผิด — และมันผิดแบบ "ปลอดภัยจนน่ากลัว" คือแสดงผลว่าไม่มีอะไรเกิดขึ้น (ฝน 0) ซึ่งในระบบเตือนภัยแปลว่า "ปลอดภัยดี ไม่ต้องระวัง" ทั้งที่ความจริงตรงกันข้าม บทเรียนตรงนี้: defensive code อย่าง clamp ที่กัน crash ได้ ก็เปลี่ยนบั๊กที่ดังให้กลายเป็นบั๊กที่เงียบได้เหมือนกัน การ clamp ควรมาพร้อม assertion ว่า "ถ้าต้อง clamp เยอะผิดปกติ = สมมติฐานเรื่องขนาดภาพพัง" ให้มัน log warning ดังๆ ไม่ใช่กลืนเงียบ
ทางแก้ที่ผิด vs ถูก: recalibrate ทั้งตาราง vs resize-normalize กลับ
ตอนแรกผมเกือบเดินทางผิด — คิดจะ recalibrate ตารางทั้งใบให้ตรงกับ 800×800 นั่นคือการนั่งเล็ง landmark ใหม่ทุกจุดในภาพขนาดใหม่ ซึ่งเป็นงานมือที่แพง เสี่ยงคลาดเคลื่อน และที่แย่สุดคือ มันแก้ปัญหาแค่ครั้งเดียว — วันที่ upstream เปลี่ยนขนาดอีก (ซึ่งเขาทำจริง มีทั้ง variant ย่อและ variant เต็ม สลับไปมา) ผมก็ต้อง recalibrate อีกรอบไม่จบสิ้น
กุญแจที่ทำให้เลือกทางถูกคือคำถามเดียว: "การย่อครั้งนี้เป็น transform ที่ inverse กลับได้ไหม?" ในเคสนี้ 800×800 คือ 1920×1600 ที่ถูกย่อด้วยสัดส่วนคงที่ (x÷2.4, y÷2.0) — มัน inverse ได้ ผมแค่ resize ภาพกลับเป็น 1920×1600 ก่อนป้อนเข้าตัววิเคราะห์ แล้ว calibration table เดิมก็ใช้ได้ทันทีโดยไม่ต้องแตะสักบรรทัด
def load_radar_image(path):
img = cv2.imread(path)
h, w = img.shape[:2]
# ตารางพิกัดทั้งหมด calibrate ที่ resolution นี้ = สัญญาที่ต้องรักษา
CALIB_W, CALIB_H = 1920, 1600
if (w, h) != (CALIB_W, CALIB_H):
# upstream ย่อภาพ -> normalize กลับก่อน map ไม่ใช่ไป recalibrate
# INTER_NEAREST สำคัญมาก (เหตุผลในหัวข้อถัดไป)
img = cv2.resize(img, (CALIB_W, CALIB_H),
interpolation=cv2.INTER_NEAREST)
log.warning(f"Normalizing radar image {w}x{h} -> {CALIB_W}x{CALIB_H}")
return img
หลักการ transferable: อย่าให้ calibration table แบกภาระเรื่องขนาด input ให้ตารางผูกกับ "resolution มาตรฐานที่คุณ calibrate ไว้" เพียงค่าเดียว แล้วสร้าง normalization layer ที่บีบทุก input ให้กลับมาที่มาตรฐานนั้นก่อนเสมอ ตารางกลายเป็น single source of truth ที่ไม่ต้องแก้ ส่วน layer นอกจัดการความหลากหลายของ input แทน
ทำไม INTER_NEAREST สำคัญเมื่อภาพเป็น palette ที่มีความหมาย
ตอน resize รายละเอียดที่คนพลาดง่ายสุดคือ interpolation mode ปกติเวลาขยายภาพถ่ายทั่วไป เราใช้ bilinear/bicubic เพราะมันเกลี่ยสีให้เนียนตา สวยกว่า แต่ในเคสนี้ถ้าใช้มันจะ ทำลายข้อมูล
เพราะภาพเรดาร์ไม่ใช่ภาพถ่าย มันเป็นภาพ palette — แต่ละสีคือ "รหัส" ที่แทนค่าจริงเป๊ะๆ (เขียว = ฝนเบา, เหลือง = ปานกลาง, แดง = หนัก, ม่วง = รุนแรง) สีในภาพนี้ไม่ต่อเนื่อง มันเป็นชุดสีจำกัดที่แต่ละสีมีความหมายตายตัว
ถ้า resize ด้วย bilinear พิกเซลใหม่จะถูกคำนวณเป็น "ค่าเฉลี่ยของเพื่อนบ้าน" — ขอบระหว่างโซนเขียวกับโซนแดงจะเกิดสีเหลือง/ส้มที่ ไม่เคยมีในภาพจริง ตอนเอาไปเทียบกับตาราง COLOR→dBZ สีที่ interpolate ขึ้นมาใหม่อาจถูก match เป็นความเข้มฝนที่ไม่มีอยู่จริง หรือหลุดตารางไปเลย INTER_NEAREST (nearest-neighbor) แก้ปัญหานี้เพราะมันแค่ "ก็อปสีของพิกเซลที่ใกล้ที่สุด" มาวาง ไม่สร้างสีใหม่ — ทุกพิกเซลหลัง resize ยังเป็นสีที่มีอยู่จริงใน palette เดิม
หลักการ transferable: เวลา resize/transform ข้อมูลที่ "สีหรือค่าคือรหัส" (categorical/palette) ห้ามใช้ interpolation ที่เฉลี่ยค่า ไม่ว่าจะเป็น radar palette, segmentation mask, หรือ label map — ต้องใช้ nearest-neighbor เสมอ ไม่งั้นคุณจะสร้าง category ปลอมที่ไม่มีในต้นทาง
Verify ด้วยการ overlay landmark ก่อนเชื่อ
ผมไม่ยอมเชื่อว่า fix ใช้ได้จนกว่าจะเห็นด้วยตา วิธี verify ที่ทรงพลังที่สุดสำหรับงาน pixel→world คือ overlay กรอบพิกัดจากตารางทับลงบนภาพจริง แล้วดูว่ามันไปตกตรง landmark ที่รู้ตำแหน่งจริงหรือเปล่า
ผมเอา bounding box ของแต่ละ cell ในตารางมาวาดทับบนภาพที่ normalize แล้ว ถ้ากรอบของ cell ที่ควรเป็น "แม่น้ำสายนี้" ไปตกบนแม่น้ำจริงในภาพพอดี, กรอบของ "ชายแดนจังหวัดนี้" ตกบนเส้นแบ่งจริง — แปลว่า framing ตรงทุกจุด ตารางเดิมยังใช้ได้ ผมยืนยันได้ว่า resize กลับให้ framing เป๊ะเหมือนต้นฉบับ ไม่ใช่แค่ "ขนาดถูกแต่เลื่อน"
นี่คือหัวใจของ RULE ที่ผมยึด: ผลลัพธ์ที่ไม่ throw ไม่ได้แปลว่าถูก การเห็นเลข "47 cells เจอฝน" (จากเดิม 0) ยังไม่พอ ต้องเห็นว่ากรอบมันวางถูกที่บนโลกจริงด้วย overlay คือ proof ระดับ runtime ที่พิสูจน์ทั้ง "ค่าถูก" และ "ตำแหน่งถูก" พร้อมกัน ก่อนหน้านั้นทุกอย่างคือสมมติฐาน
Fix แบบ generic ที่แก้ทุก station เผื่ออนาคต
สิ่งที่ทำให้ผมพอใจกับ fix นี้ที่สุดคือมัน generic ผมไม่ได้เขียนโค้ดว่า "ถ้าเป็น 800×800 ให้ทำแบบนี้" ซึ่งจะพังทันทีถ้าวันหน้า upstream เสิร์ฟ 640×640 หรือ 1024×1024 แต่ผมเขียนกฎเดียวว่า "ภาพใดก็ตามที่ขนาด ≠ resolution มาตรฐาน ให้ normalize กลับมาก่อน"
ผลพลอยได้คือ fix นี้แก้ปัญหาให้ ทุก station ในระบบพร้อมกัน ไม่ใช่แค่ตัวที่ร้องเรียน สถานีอื่นที่เคยโดนย่อเหมือนกันและถูกปล่อย "ค้าง" ไว้ ก็แค่เปิด capture กลับมาแล้วมันจะ auto-resize ใช้ตารางเดิมได้ทันที ไม่ต้อง recalibrate ตามที่ผมเคยกลัวไว้ตอนแรกเลย และเมื่อวันหนึ่งต้นทางคืน variant ความละเอียดเต็มกลับมา โค้ดก็เห็นว่าขนาดตรงมาตรฐานอยู่แล้ว ข้าม resize ไปเฉยๆ ไม่มี regression
หลักคิดที่ผมได้: เวลาแก้บั๊กจากปัจจัยภายนอกที่คุมไม่ได้ (vendor, upstream API, hardware) ให้ถามว่า "ความเปลี่ยนแปลงนี้เป็นสมาชิกของ family ที่กว้างกว่าไหม" ถ้าใช่ ให้แก้ที่ระดับ family — เขียนกฎที่ครอบทุก case ในตระกูล ไม่ใช่ patch เฉพาะ instance ที่บังเอิญเจอ เพราะ upstream ที่เปลี่ยนได้ครั้งหนึ่ง จะเปลี่ยนอีกแน่นอน
สรุป
- calibration table ผูกกับ resolution เสมอ — ค่า pixel ในตารางมีความหมายเฉพาะกับขนาดภาพที่ใช้ตอน calibrate บันทึก resolution นั้นไว้ให้ชัด
- input เปลี่ยนขนาด ให้ normalize กลับก่อน map อย่ารีบ recalibrate ทั้งตาราง ถ้าการย่อเป็น transform ที่ inverse ได้ การ resize กลับคือคำตอบที่ถูกและถูกกว่า
- ระวังบั๊กที่ไม่ throw — clamp/defensive code กัน crash ได้ แต่เปลี่ยนบั๊กดังให้เงียบ ควรมี warning เมื่อสมมติฐาน (เช่นขนาดภาพ) ถูกละเมิด
- ข้อมูล palette/categorical ต้อง resize ด้วย nearest-neighbor interpolation ที่เฉลี่ยค่าจะสร้าง category ปลอมที่ไม่มีในต้นฉบับ
- verify ด้วย overlay บน landmark จริง เลขที่ดูดีขึ้นยังไม่พอ ต้องเห็นว่าตำแหน่งวางถูกบนโลกจริง
- แก้ที่ระดับ family ไม่ใช่ instance ปัจจัยภายนอกที่เปลี่ยนได้ครั้งหนึ่งจะเปลี่ยนอีก เขียนกฎ generic ที่ครอบทุก case เผื่ออนาคต




