1 คะแนน โดย GN⁺ 7 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ในงาน การทำ normalize ค่า RGB หากเป็นกรณีทั่วไปที่ต้องประมวลผลไฟล์ภาพที่ไม่คุ้นเคยแล้วบันทึกกลับเป็น 8 บิตอีกครั้ง วิธีมาตรฐานที่หารด้วย 255 เหมาะสมกว่า
  • วิธี 255 จะแมป 0 เป็น 0.0 และ 255 เป็น 1.0 ทำให้จัดการ สีดำและสีขาว ได้โดยตรง และยังสอดคล้องกับวิธีแปลง UNORM-to-float ของ GPU
  • วิธี 256 ใช้ (img + 0.5) / 256.0 เพื่อวางแต่ละค่าไว้กึ่งกลางของช่วง ทำให้งานอย่าง dithering จัดการขอบเขตได้ง่ายขึ้น แต่ 0 จะไม่เท่ากับ 0.0 ทำให้ตรรกะการประมวลผลผูกกับอินพุต 8 บิต
  • วิธี 255 มีช่วงปลายทั้งสองด้านกว้างเพียงครึ่งเดียวของช่วงอื่น ๆ ดังนั้นถ้าสุ่มค่า [0, 1] แบบสม่ำเสมอแล้วปัดกลับเป็น 8 บิต ค่า 0 และ 255 จะเกิดขึ้นด้วยความถี่เพียงครึ่งหนึ่งของค่าอื่น แต่การแปลงภาพไป-กลับจริงยังทำงานได้โดยไม่สูญเสียข้อมูล
  • วิธี 256 ในทางทฤษฎีมีค่าเฉลี่ยความคลาดเคลื่อนสัมบูรณ์ 1 / 1024 ซึ่งน้อยกว่าวิธี 255 ที่ 1 / 1020 แต่ถ้าอ่านภาพที่ถูก quantize มาด้วยวิธี 255 ด้วยสเกลที่ผิด ก็จะยิ่งเพิ่มความคลาดเคลื่อนแทน

การตั้งปัญหา

โปรแกรมประมวลผลภาพจะแปลงภาพ 8 บิตเป็นเลขทศนิยมแบบ floating point ประมวลผล แล้วบันทึกกลับเป็นค่าสี 8 บิตอีกครั้ง

การแปลงสองแบบมีดังนี้

# มาตรฐาน: หารด้วย 255
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)


# ทางเลือก: บวก 0.5 แล้วหารด้วย 256
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)

ทั้งสองวิธีจะจำกัดค่าให้อยู่ในช่วง 0~255 ก่อนแปลงขั้นสุดท้าย

output_8bit = output.clip(0, 255).astype(np.uint8)

วิธีมาตรฐานจะแมปจำนวนเต็ม 0 ไปเป็น 0.0 และ 255 ไปเป็น 1.0 ซึ่งเหมือนกับวิธี แปลง UNORM-to-float ของ GPU

ส่วนวิธีทางเลือกจะแมป 0 ไปเป็น 0.5 / 256 = 0.001953125 ดังนั้นถ้าต้องการตรวจจับพิกเซลดำ ก็จำเป็นต้องรู้ค่าคงที่นี้

คุณสมบัติของวิธีมาตรฐานที่หารด้วย 255

วิธีมาตรฐานทำให้ช่วงของค่าปลายทั้งสองด้านภายในขอบเขต [0, 1] มีความกว้างเพียงครึ่งเดียวของช่วงอื่น ๆ

หากสร้างเลขสุ่มแบบสม่ำเสมอในช่วง [0, 1] แล้วปัดด้วย trunc(result * 255 + 0.5) ค่า 0 และ 255 จะปรากฏด้วยความถี่เพียงครึ่งหนึ่งของจำนวนเต็มอื่น

แต่ภาพ 8 บิตต้นฉบับยังสามารถแปลงไป-กลับแบบ uint8 → float → uint8 ได้โดยไม่สูญเสียข้อมูล

นอกจากนี้ แม้ผลการประมวลผลจะเลย 0.0 หรือ 1.0 ออกไปเล็กน้อย ก็ยังสามารถถูก clamp และปัดกลับเข้าสู่ช่วงจำนวนเต็มที่ถูกต้องได้

ตัวอย่างเช่น ถ้าลบ 0.005 ออกจากค่าสี floating point สีดำของวิธีมาตรฐานจะกลายเป็นค่าติดลบ แต่ผลลัพธ์สุดท้ายยังคงเป็นจำนวนเต็ม 0

trunc(255 * (-0.005) + 0.5) = 0

ความแม่นยำของ floating point และการวางไว้กึ่งกลางช่วง

ค่าของวิธี 255 บางค่าไม่สามารถแทนได้อย่างแม่นยำพอดี

ตัวอย่างเช่น 128 / 255.0 ≈ 0.501961 แต่ 128 / 256.0 = 0.5

ความต่างนี้เป็นเพียงความคลาดเคลื่อนจากการปัดเศษระดับบิตล่างสุดของ mantissa 23 บิตใน floating point แบบ 32 บิต และมีขนาดเล็กกว่า 2^-23

ดังนั้นความไม่แม่นยำนี้จึงใกล้เคียงกับประเด็นด้านความสวยงามมากกว่าจะเป็นปัญหาทางเทคนิคจริง

วิธี 256 จะวางค่าทศนิยมแต่ละค่าไว้ตรงกึ่งกลางระหว่างจำนวนเต็มสองค่าอย่างพอดี

คุณสมบัตินี้มองได้ว่าเป็นการประนีประนอมโดยใช้จุดกึ่งกลางระหว่างจำนวนเต็มที่อยู่ติดกัน เมื่อเราไม่รู้แน่ชัดว่าค่าที่ถูก quantize มาแต่เดิมคืออะไร

บทความปี 2015 ของ Andrew Kesler “Converting Color Depth” มองว่าวิธีนี้ทำให้การเพิ่ม noise ในงาน dithering ต้องกังวลเรื่องการจัดการขอบเขตน้อยลง

ในทางกลับกัน ช่วงปลายของวิธีมาตรฐานต้องการ การจัดการอย่างระมัดระวัง หากต้องการให้การกระจายของ noise สม่ำเสมออย่างต่อเนื่อง

มุมมองด้าน quantization

ทั้งสองวิธีสามารถมองเป็น uniform scalar quantizer ได้

คำอธิบายเรื่อง quantization บน Wikipedia) อธิบาย uniform quantizer สำหรับ signed input data โดยหลัก ๆ แบ่งเป็น mid-riser และ mid-tread

mid-tread จะมี reconstruction level สำหรับค่า 0 ส่วน mid-riser จะมี classification threshold ที่ค่า 0

สูตรมีความสอดคล้องดังนี้

วิธี การเข้ารหัส การถอดรหัส
mid-tread k = trunc(x L + 0.5) y_k = k / L
mid-riser k = trunc(x L) y_k = (k + 0.5) / L

วิธีมาตรฐานเป็นรูปแบบ mid-tread ที่ใช้ L=255 และวิธีทางเลือกเป็นรูปแบบ mid-riser ที่ใช้ L=256

วิธีมาตรฐานได้ความสะดวกในการเขียนโปรแกรมจากการตรึงปลายช่วงไว้ที่ 0.0 และ 1.0 แต่แลกมากับการจัดวางช่วงที่ไม่ใช่แบบเหมาะที่สุดสำหรับอินพุต 8 บิต

ความคลาดเคลื่อนของการสร้างกลับและการประมวลผลภาพจริง

หากออกแบบระบบที่เข้ารหัสจำนวนจริง x ∈ [0, 1] ซึ่งมีการกระจายแบบสม่ำเสมอให้เป็นจำนวนเต็ม 8 บิต แล้วสร้างกลับเป็นจำนวนจริงด้วยตัวเอง วิธี 256 จะมีความแม่นยำทางทฤษฎีสูงกว่า

ช่วงที่วิธีมาตรฐานแทนได้คือ [-0.5 / 255, 255.5 / 255] ทำให้ระยะห่างของช่วงกว้างกว่าที่จำเป็นสำหรับ [0, 1]

ตามการคำนวณของผู้ใช้ StackOverflow Peter Mudrievskij ค่าเฉลี่ยความคลาดเคลื่อนสัมบูรณ์คือ 1 / 1020 สำหรับการหารด้วย 255 และ 1 / 1024 สำหรับการหารด้วย 256

แต่ในสถานการณ์ที่อ่านและประมวลผลภาพ RGB 8 บิตที่ถูกบันทึกไว้แล้ว ข้อมูลที่สูญเสียไปตอนบันทึกจะไม่สามารถกู้คืนกลับมาได้

หากภาพถูก quantize มาโดยการคูณด้วย 255 แล้วปัดเศษ การโหลดด้วยการหาร 256 ก็ไม่ได้ทำให้ความละเอียดกลับคืนมา

ภาพที่คนอื่นสร้างขึ้นส่วนใหญ่น่าจะถูก quantize ด้วยวิธีมาตรฐาน ดังนั้นการอ่านด้วยสูตรทางเลือกจึงเท่ากับใช้ scale factor ที่ผิดในเชิงทฤษฎี

ในทางปฏิบัติ สีไม่ได้ทำงานเหมือนค่าที่วัดได้แบบสัมบูรณ์ จึงจะกลายเป็นการประมวลผลภายใต้ช่วงค่าที่เล็กลงเล็กน้อยและมี offset เล็กน้อยแทน

ถ้านำขั้นตอนเข้ารหัสและถอดรหัสของ quantizer ทั้งสองแบบมาปะปนกัน โค้ดก็จะผิดเพี้ยน

สรุป

หากต้องประมวลผลภาพที่มาจากแหล่งที่ไม่คุ้นเคย ควรทำ normalize ค่า RGB ด้วย 255

เหตุผลอย่างเรื่องค่าทศนิยมไม่แม่นยำพอดี หรือความรู้สึกว่า reconstruction error เชิงนามธรรมสูงกว่า ไม่ใช่เหตุผลที่หนักแน่นพอจะเลือกวิธี 256

ถ้าคุณควบคุมทั้งการบันทึกและการโหลดภาพเอง ไม่จำเป็นต้องให้ 0 แมปเป็น 0 และยอมรับได้ที่โค้ดประมวลผลจะผูกกับ dynamic range แบบ 8 บิต ก็อาจเลือกหารด้วย 256 เพื่อหวังความแม่นยำทางทฤษฎีที่สูงขึ้นเล็กน้อยได้

1 ความคิดเห็น

 
GN⁺ 7 시간 전
ความเห็นจาก Lobste.rs
  • ถึงจะดูเลอะเทอะแต่ถูกต้องแล้ว ค่าที่ถูกต้องคือ 255
    ถ้ายังไม่ค่อยเป็นธรรมชาติ ลองดูกรณีเสื่อมแบบ 2 บิตก็ได้ เมื่อมีค่า integer ที่เป็นไปได้แค่ 0, 1, 2, 3 แล้วคำนวณการแปลง integer→floating point ทั้งหมด จะเห็นว่าถ้าอยากหลีกเลี่ยงพฤติกรรมประหลาดอย่างสีดำ/สีขาวไม่เป็นดำ/ขาวจริง หรือระยะห่างที่ไม่สม่ำเสมออย่างชัดเจน ค่าที่ได้จะเป็น 0.0, 0.33..., 0.66..., 1.0
    ดังนั้นการแปลงกลับจึงเป็นการ คูณด้วย 3 ไม่ใช่ 4(2^2)
    • ช่วงต้นถูกต้อง แต่จากตรงนั้นไม่ได้แปลว่า “ตอนแปลงกลับต้องคูณ 3 ไม่ใช่ 4”
      การแปลงกลับต้องมี การควอนไทซ์ (ปัดเศษ) และนี่แหละคือจุดสำคัญที่ทำให้สมมาตรถูกทำลาย
      ถ้าสร้างไล่ระดับของจำนวนจริงแบบสม่ำเสมอในช่วง 0..=1 แล้วควอนไทซ์เป็น 0, 1, 2, 3 จะเห็นว่าถ้าคูณ 3 ผลลัพธ์จะไม่สม่ำเสมอ หลัง ×3 แล้ว round() จะทำให้ 1 และ 2 ถูกแทนมากเกินไป ส่วน floor หรือ ceil หลัง ×3 จะบีบ 0 หรือ 3 ให้กลายเป็นค่าพิเศษ จนดูเหมือนไล่ระดับนั้นใช้แค่ 3 สีจากทั้งหมด 4 สี
      ตรรกะ /3 กับ ×3 อาจดูใช้ได้เมื่อแปลงค่าตัวเลขที่แน่นอนแบบไปกลับ แต่ค่าระหว่างกลางได้รับผลจากการเลือกวิธีปัดเศษอย่างมาก และจะสำคัญทันทีที่เริ่มประมวลผลข้อมูล
      วิธีเดียวที่จะทำให้อัตราส่วนของจำนวนเต็มสม่ำเสมอคือ คูณด้วย (4-ε) แล้วปัดลง ซึ่งเทียบเท่ากับ ×4, floor(), clamp() ถึงจะให้ความรู้สึกเหมือนมีข้อผิดพลาดแปลก ๆ แบบคลาด 1 หรือคลาด ε แต่ในเชิงสัญชาตญาณมันคือทางแก้ที่ดูดีที่สุด
  • หัวข้อทำให้สับสนมากทีเดียว ไม่แน่ใจว่าตั้งใจหรือเปล่า แต่สุดท้ายมันดูเหมือนเป็นคำถามว่า “0..1 สอดคล้องกับ [0..255.0] หรือ [0.5..255.5] กันแน่?”
    สำหรับผมคำตอบคือ “แน่นอนอยู่แล้ว” ว่าเป็น [0.0..255.0] แต่ดูเหมือนคงไม่ใช่สิ่งที่ชัดเจนสำหรับทุกคน
    ในบทความบอกว่าช่วง “ปลายสุด” มีความจุเพียงครึ่งหนึ่งของช่วงอื่น ๆ แต่ผมว่ากรอบแบบนั้นก็ไม่ถูก
    ถ้าไม่มีค่าใดอยู่นอก [0..1] การที่มันดูเหมือนช่วงแคบกว่าก็เป็นผลจากการเรนเดอร์เท่านั้น เป็นเพียงเพราะคุณตัดบัคเก็ตโดยอาศัยความรู้ว่าไม่มีค่านอกช่วง
    ในทางกลับกัน ถ้ามีค่าที่อยู่นอก [0..1] ช่วงนั้นก็ไม่มีที่สิ้นสุด บทความยอมรับอย่างหลัง แต่ไม่ยอมรับอย่างแรก
    ทันทีที่ยอมรับข้อแรก พฤติกรรมที่ถูกต้องก็ดูชัดเจน แต่การที่มีบทความแบบนี้ออกมาเองก็หมายความว่าโดยเชิงวัตถุวิสัยแล้วมันไม่ใช่ปัญหาที่ “ชัดเจน” ขนาดนั้น :D
    • ถ้า 0…255.0 เป็นสิ่งที่แน่นอนจริง ๆ แล้ว ช่วงของค่า floating point แบบไหนควรย้อนกลับไปเป็น integer 0 และค่าแบบไหนควรย้อนกลับไปเป็น integer 255?
      ถ้า 0..<1 ไปเป็น integer 0 และ 254>..255.0 ไปเป็น integer 255 อย่างนั้น 128 ก็จะหายไป คุณคงอยากให้ 127.5..128.5 ไปเป็น 128 แต่ถ้าอย่างนั้นครึ่งช่วงเหล่านี้ควรไปอยู่ที่ไหน?
      ถ้าขยับทั้งระบบนิดหน่อยเพื่อให้ 128 ถูกต้อง ก็จะกลายเป็นว่า 0..0.99609375 ถูกแมปเป็น integer 0
  • แนวทางมาตรฐานก็ดูเหมือนจะเกิดจากการที่คนเรียก round() กันตามธรรมชาติ
    วิธีนั้นให้ความรู้สึกเป็นธรรมชาติพอสมควรสำหรับผู้คน เลยอาจกลายเป็นมาตรฐานเพราะความเรียบง่าย
  • สงสัยว่าวิธีตรงข้ามกับสิ่งที่พยายามทำด้วย 256 จะมีประโยชน์ไหม กล่าวคือส่ง 0.0 ไปเป็น 0, 1.0 ไปเป็น 255 และแมปค่า floating point อื่น ๆ ทั้งหมดไปอยู่ระหว่าง 1 ถึง 254
    uint8_t output = 0.0f >= result  
                     ? 0  
                     : 1.0f <= result  
                     ? 255  
                     : 1 + 253*result;  
    
    อยากให้ระหว่างการประมวลผล สีดำยังคงเป็นสีดำ และสีขาวยังคงเป็นสีขาว
    • ถ้าทำแบบนี้ 0 และ 255 จะได้ส่วนแบ่งในช่วงหน่วยมากกว่าตัวเลขอื่น ๆ ประมาณ 0.8% หรือก็คือ 255/253
  • ภาพแรกในเครื่องผมดูเสีย
    • ผู้เขียนบทความเองครับ หมายถึงไฟล์ภาพเสียหรือเปล่า? ผมบีบอัดด้วย pngcrush หรือหมายถึงว่ามีอะไรผิดกับเนื้อหาของภาพ?