จับสีจากภาพจริงต้องเผื่อ tolerance: ทำไม exact RGB match พังกับภาพที่ถูกบีบอัด
อยากอ่านค่าเชิงปริมาณจากสีในภาพ (เช่นความเข้มฝนจาก legend สี) แต่ exact RGB match ดันเจอสีไม่ตรงตาราง? ปัญหาไม่ได้อยู่ที่โค้ด — มันอยู่ที่ anti-aliasing กับ JPEG compression. มาดูวิธีแปลงเข้า HSV, ถ่วงน้ำหนัก channel, และตั้ง tolerance จากข้อมูลจริง
อ่าน ~8 นาที
ปัญหา: สีในตารางมี 22 สี แต่ pixel จริงไม่ตรงสักตัว
ผมเคยทำ pipeline ที่ต้องอ่านค่าเชิงปริมาณออกมาจากสีในภาพ — เคสของผมคือแปลงภาพเรดาร์ฝนให้เป็นตัวเลขความเข้ม โดยภาพมันใช้ legend สีมาตรฐาน แต่ละสีแทนช่วงค่าหนึ่ง (เขียว = ฝนเบา, เหลือง = ปานกลาง, แดง = หนักมาก) ผมมีตาราง lookup ที่ map สี RGB แต่ละตัวไปเป็นค่าที่ต้องการ ตรงไปตรงมามาก: อ่านสี pixel ออกมา เทียบกับตาราง ได้ค่า จบ
ปัญหาโผล่ทันทีที่รันกับภาพจริง — pixel ส่วนใหญ่ไม่ตรงกับสีไหนในตารางเลย ตาราง define สีเขียวเป็น (0, 255, 0) แต่ pixel ที่ตาคนเห็นว่าเขียวชัดๆ กลับอ่านออกมาเป็น (3, 251, 8) บ้าง (0, 248, 12) บ้าง exact match เลยพังยับ — pixel ตกไปกอง "other" หมด ค่าที่คำนวณได้เพี้ยนไปคนละเรื่อง
แก่นของบทความนี้คือ: สีในไฟล์ภาพไม่ใช่สีที่คนวาดตั้งใจ ทันทีที่ภาพผ่าน anti-aliasing, JPEG compression, หรือ resampling สีทุก pixel ถูกกวนให้เพี้ยนเล็กน้อย การอ่านค่าจากสีจึงต้องคิดแบบ "หาสีที่ใกล้ที่สุด" ไม่ใช่ "หาสีที่ตรงเป๊ะ" และวิธีวัด "ใกล้" ก็มีรายละเอียดที่ทำพลาดง่าย
ทำไม exact RGB match พัง: anti-aliasing, compression, resampling
มีสามกลไกหลักที่ทำให้สีจริงในไฟล์ไม่ตรงกับสีต้นฉบับ:
- Anti-aliasing — ตรงขอบระหว่างสองสี renderer จะ blend สีให้ขอบเนียน pixel ที่คร่อมขอบเขียว/เหลืองจะได้สีที่ผสมกันครึ่งๆ ไม่ใช่เขียวแท้หรือเหลืองแท้
- Lossy compression (JPEG) — JPEG ทำงานบน block 8×8 แปลงเป็นความถี่แล้วตัดรายละเอียดทิ้ง สีในแต่ละ block เลยขยับจากค่าเดิม โดยเฉพาะ channel ความสว่างจะโดนกวนหนัก ยิ่งบริเวณที่สีตัดกันแรงยิ่งเพี้ยน (ringing artifact)
- Resampling / resize — พอภาพถูกย่อหรือขยาย interpolation (bilinear/bicubic) จะเฉลี่ยสีของ pixel ข้างเคียงเข้าด้วยกัน สร้างสีกลางที่ไม่เคยมีในต้นฉบับ
บทเรียนที่ transferable: ถ้า input ของคุณคือภาพที่ผ่าน pipeline การผลิต/บีบอัดมาก่อน อย่าออกแบบระบบบนสมมติฐานว่าค่า pixel จะเป๊ะ ต่อให้ต้นทางเป็น PNG lossless ก็ตาม เพราะขั้น resize หรือ anti-alias ก็เพี้ยนได้อยู่ดี exact match คือกับดักที่ดูสะอาดตอนเขียนแต่พังทันทีที่เจอข้อมูลโลกจริง
ทำไม HSV ดีกว่า RGB สำหรับ perceptual matching
ปัญหาของ RGB คือมันผสมข้อมูลสองอย่างที่ควรแยกกันไว้ในทุก channel: R, G, B แต่ละตัวมีทั้ง "โทนสี" และ "ความสว่าง" ปนกัน พอ compression ไปกวนความสว่าง มันเลยเลื่อนทั้งสาม channel พร้อมกัน ทำให้ "ระยะห่าง" ใน RGB space ระหว่างเขียวสว่างกับเขียวหม่นดูไกลเกินจริง ทั้งที่คนเห็นว่าเป็นสีเดียวกัน
HSV แยกสามมิตินี้ออกจากกันชัดเจน:
- Hue — โทนสี (เขียว/เหลือง/แดง) นี่คือสิ่งที่บอกความหมายจริงๆ ในงานอ่านค่าจาก legend
- Saturation — ความอิ่มของสี
- Value — ความสว่าง ซึ่งเป็น channel ที่โดน compression กวนหนักที่สุด
พอแยกได้แบบนี้ เราจะให้น้ำหนักแต่ละมิติตามความหมายของมันได้ ในงานที่สีบอกปริมาณ Hue คือพระเอก ส่วน Value เป็นตัวที่เชื่อถือไม่ได้เพราะเพี้ยนง่าย การจัดการเรื่องนี้ใน RGB ทำแทบไม่ได้เลยเพราะข้อมูลมันปนกันหมด
ถ่วงน้ำหนัก channel: hue สำคัญสุด, value ลดน้ำหนัก
ผมคำนวณระยะห่างใน HSV แบบถ่วงน้ำหนัก ให้ Hue มีน้ำหนักมากสุด และกด Value ลงเพราะรู้ว่ามันเป็น channel ที่ compression ทำให้เพี้ยน น้ำหนักที่ผมใช้: Hue 2.0, Saturation 1.0, Value 0.5
import colorsys
def hsv_distance(c1_rgb, c2_rgb):
# แปลง RGB (0-255) -> HSV (0-1) ก่อน
h1, s1, v1 = colorsys.rgb_to_hsv(*[x / 255 for x in c1_rgb])
h2, s2, v2 = colorsys.rgb_to_hsv(*[x / 255 for x in c2_rgb])
# hue เป็นวงกลม (0 กับ 1 คือสีเดียวกัน) ต้องหาระยะที่สั้นกว่า
dh = min(abs(h1 - h2), 1 - abs(h1 - h2))
ds = abs(s1 - s2)
dv = abs(v1 - v2)
# น้ำหนัก: hue สำคัญสุด (โทนสี = ความหมาย),
# value ครึ่งเดียวเพราะโดน compression กวนหนัก
W_H, W_S, W_V = 2.0, 1.0, 0.5
return (dh * W_H + ds * W_S + dv * W_V) * 255 # scale กลับให้อ่านง่าย
จุดที่คนพลาดบ่อยคือ Hue เป็นค่าเชิงวงกลม — สี hue = 0.99 กับ hue = 0.01 ต่างกันแค่นิดเดียว (แดงทั้งคู่) ไม่ใช่ต่างกันเกือบเต็มวง ถ้าลบตรงๆ จะได้ระยะผิดมหาศาลตรงรอยต่อสีแดง ต้อง wrap ด้วย min(d, 1-d) เสมอ หลักการ transferable: น้ำหนักของแต่ละ feature ควรสะท้อน (ก) ความหมายของมันต่องาน และ (ข) ความน่าเชื่อถือของมันหลังผ่าน noise — feature ที่เพี้ยนง่ายต้องถูกกดน้ำหนักลง
ผสม distance metric: RGB_dist กับ HSV_dist ในสัดส่วนที่ทดลองแล้ว
HSV ดีสำหรับจับโทนสี แต่มันก็มีจุดอ่อน — ตรงสีที่ saturation ต่ำมาก (เกือบเทา/ขาว/ดำ) ค่า hue จะไม่เสถียร กระโดดไปมาได้แม้ RGB ขยับนิดเดียว ผมเลยไม่ทิ้ง RGB distance ไปเลย แต่ผสมทั้งสอง metric ให้ RGB ช่วยยึด HSV ไว้ในโซนที่ HSV ไม่น่าเชื่อถือ สัดส่วนที่ผมลงตัวหลังทดลองคือ RGB 30% + HSV 70%:
def color_distance(pixel_rgb, table_rgb):
# RGB distance ธรรมดา (euclidean)
rgb_dist = sum((a - b) ** 2 for a, b in zip(pixel_rgb, table_rgb)) ** 0.5
hsv_dist = hsv_distance(pixel_rgb, table_rgb)
# ผสม: HSV เป็นหลัก (จับโทนสีตามความหมาย)
# RGB เป็นตัวช่วย (ยึดโซนที่ hue ไม่เสถียร)
return rgb_dist * 0.3 + hsv_dist * 0.7
สัดส่วน 0.3/0.7 ไม่ได้มาจากทฤษฎีสวยๆ — มันมาจากการลองกับภาพจริงแล้วดูว่าอันไหนจับสีได้ตรงที่สุด นี่คือประเด็นสำคัญ: metric ที่ผสมกันเช่นนี้เป็น hyperparameter ค่าที่เหมาะกับ domain ผมอาจไม่เหมาะกับคุณ อย่าลอกตัวเลขไปใช้ ให้ลอกวิธีคิด — ผสม metric ที่จุดแข็งชดเชยจุดอ่อนกัน แล้วหาสัดส่วนจากข้อมูลจริงของคุณเอง
ตั้ง tolerance threshold จากข้อมูลจริง ไม่ใช่เดา
เมื่อวัดระยะได้แล้ว ขั้นต่อไปคือตัดสินว่า "ใกล้พอจะนับว่าเป็นสีนี้ไหม" — นั่นคือ tolerance threshold ถ้าตั้งแคบไป pixel ที่เพี้ยนนิดหน่อยจะตกหล่น ถ้าตั้งกว้างไป pixel สีอื่นจะถูกนับผิดประเภท ผมตั้ง threshold ที่ 50 หน่วย (บน scale ของ metric ผสมด้านบน) แต่ตัวเลขนี้ไม่ได้เดาเอา — ผมได้มาจากการวัด distribution ของ distance จริง
def find_nearest_color(pixel_rgb, color_table, tolerance=50):
best_color, best_dist = None, float('inf')
for name, ref_rgb in color_table.items():
d = color_distance(pixel_rgb, ref_rgb)
if d < best_dist:
best_color, best_dist = name, d
# ไกลเกิน tolerance = ไม่ใช่สีใน legend (พื้นหลัง/ตัวอักษร/เส้น grid)
if best_dist > tolerance:
return None
return best_color
วิธีหา threshold: เอาภาพตัวอย่าง รัน distance กับทุก pixel แล้วดู histogram มันจะเห็นเป็นสอง cluster ชัดเจน — กองซ้าย (distance ต่ำ) คือ pixel ที่ตรงสี legend จริง กองขวา (distance สูง) คือพื้นหลัง/เส้น/ตัวอักษร threshold ที่ดีคือหุบเขาระหว่างสอง cluster หลักการ transferable: threshold ทุกตัวในระบบควรมีที่มาจากข้อมูล ไม่ใช่จากเลขกลมๆ ที่พิมพ์ลงไป ถ้าคุณตอบไม่ได้ว่าทำไมเป็น 50 ไม่ใช่ 30 หรือ 80 แปลว่าคุณยังไม่ได้วัด
นับเฉพาะ pixel ที่ผ่าน tolerance เพื่อไม่ให้ค่าเฉลี่ยเพี้ยน
pitfall สุดท้ายที่ subtle ที่สุด: ตอนคำนวณค่าสรุปของพื้นที่ (สีเฉลี่ย, ค่าเฉลี่ย) ต้องนับเฉพาะ pixel ที่ผ่าน tolerance เท่านั้น ห้ามเอา pixel ทั้งกรอบมาเฉลี่ย เพราะในกรอบนั้นมีทั้งเส้น grid สีเทา ตัวอักษรกำกับ พื้นหลังดำ ที่ไม่ใช่ข้อมูลจริง
def summarize_region(pixels, color_table, color_to_value, tolerance=50):
matched = []
for px in pixels:
name = find_nearest_color(px, color_table, tolerance)
if name is not None: # ข้าม pixel ที่ไม่ผ่าน (noise/พื้นหลัง)
matched.append(color_to_value[name])
if not matched:
return None # ทั้งกรอบไม่มี pixel ที่ใช่เลย
# เฉลี่ยจากเฉพาะ pixel ที่เป็นข้อมูลจริง
return sum(matched) / len(matched)
ถ้าเผลอเฉลี่ยทั้งกรอบ พื้นหลังดำ (ค่า 0) จะดึงค่าเฉลี่ยให้ต่ำผิดจริงเสมอ และยิ่งพื้นที่ว่างมากค่ายิ่งเพี้ยน หลักการที่ผมยึด: แยก "มีข้อมูล" กับ "ไม่มีข้อมูล" ออกจากกันก่อน aggregate เสมอ การเอา noise มาเฉลี่ยรวมกับ signal ทำให้ได้ตัวเลขที่ดูมีเหตุผลแต่ผิด ซึ่งอันตรายกว่าการ error โต้งๆ เพราะมันไม่มีสัญญาณเตือน
Validate: เทียบกับค่าที่รู้คำตอบก่อน trust ทั้ง pipeline
ก่อนจะเชื่อ pipeline ทั้งเส้น ผมทำ sanity check ง่ายๆ: หยิบ pixel ที่รู้คำตอบแน่ๆ (เช่น crop สีเขียวแท้จาก legend โดยตรง) ป้อนเข้าฟังก์ชัน แล้วเช็คว่ามัน map กลับเป็น "เขียว" จริง จากนั้นหยิบพื้นที่ที่คนดูภาพแล้วบอกได้ว่า "ตรงนี้ฝนหนัก" มาตรวจว่าระบบให้ค่าสูงตามไหม ถ้าค่าที่รู้คำตอบยังผิด อย่าเพิ่งไปดูค่าที่ไม่รู้คำตอบ เพราะคุณจะไม่มีทางรู้เลยว่ามันถูกหรือมั่ว หลักการนี้ใช้ได้กับทุก data pipeline ไม่ใช่แค่งานภาพ: ยึด ground-truth เล็กๆ ที่ verify ด้วยตาได้ก่อน แล้วค่อยขยายความเชื่อมั่นออกไป
สรุป
- อย่า exact-match สีจากภาพจริง — anti-aliasing, JPEG compression, resize ทำให้ค่า pixel เพี้ยนจากต้นฉบับเสมอ ต้องคิดแบบ "ใกล้ที่สุด" ไม่ใช่ "ตรงเป๊ะ"
- แปลงเข้า HSV เพื่อแยกโทนสี (hue) ออกจากความสว่าง (value) ที่โดน compression กวนหนัก — RGB ปนสองอย่างนี้ไว้จนถ่วงน้ำหนักตามความหมายไม่ได้
- ถ่วงน้ำหนัก channel ตามความหมาย + ความน่าเชื่อถือ hue มากสุด value น้อยสุด และอย่าลืม wrap hue แบบวงกลมด้วย
min(d, 1-d) - ผสม RGB กับ HSV distance ให้จุดแข็งชดเชยจุดอ่อน แล้วหาสัดส่วนจากการทดลองกับข้อมูลจริง อย่าลอกตัวเลขคนอื่น
- ตั้ง tolerance จาก histogram ของ distance จริง — threshold ที่ดีคือหุบเขาระหว่าง cluster "ตรงสี" กับ "พื้นหลัง" ไม่ใช่เลขกลมๆ ที่เดาเอา
- aggregate เฉพาะ pixel ที่ผ่าน tolerance การเอา noise มาเฉลี่ยรวม signal ให้ตัวเลขที่ดูสมเหตุสมผลแต่ผิดเงียบๆ
- validate ด้วย ground-truth ที่ verify ด้วยตาได้ก่อน เชื่อค่าที่ไม่รู้คำตอบต่อเมื่อค่าที่รู้คำตอบถูกแล้วเท่านั้น




