5.1. ออบเจกต์ Image

อัลกอริทึมประมวลผลภาพจะเดินผ่านภาพทีละพิกเซล ในแต่ละตำแหน่งจะทำสิ่งง่ายๆ -- อ่านค่า เปรียบเทียบกับค่าขีดแบ่ง รวมกับพิกเซลที่ตรงกันของภาพที่สอง หรือเขียนผลลัพธ์กลับ การตัดสินใจแบบต่อพิกเซลซึ่งทำซ้ำทั่วทั้งเฟรมนี้เองที่ประกอบกันเป็นเทคนิคการมองเห็นของเครื่องแบบคลาสสิก ไม่ว่าจะเป็นการตรวจจับขอบ การติดตามบลอบ การถอดรหัส QR และอื่นๆ เพื่อให้ทำงานได้อย่างมีประสิทธิภาพ อัลกอริทึมต้องรู้ว่าพิกเซลแต่ละตัวอยู่ที่ไหนในหน่วยความจำ ค่าของพิกเซลนั้นหมายถึงอะไร และควรดูส่วนใดของภาพ image.Image คือออบเจกต์ที่จัดการข้อมูลเหล่านั้น

Vision Sensors สิ้นสุดลงเมื่อ csi.CSI.snapshot() ส่งค่ากลับ ไม่ว่ากลไกฝั่งกล้องจะทำอะไรเพื่อสร้างเฟรมที่บันทึกนั้นก็เสร็จสิ้นแล้ว แอปพลิเคชันถือ Image ไว้ในมือและต้องรู้ว่าจะทำอะไรกับมัน

5.1.1. บัฟเฟอร์และคุณสมบัติของมัน

ภายใน Image มีพอยน์เตอร์ชี้ไปยังบล็อกไบต์ต่อเนื่องในRAM และส่วนหัวขนาดเล็กที่เก็บข้อมูลเมตาสามส่วนได้แก่ ความกว้างของภาพเป็นพิกเซล ความสูงเป็นพิกเซล และรูปแบบพิกเซลที่ไบต์เหล่านั้นอยู่ ไบต์เหล่านั้นคือพิกเซลเอง เก็บในลำดับแถวหลัก -- พิกเซลของแถวบนสุดทั้งหมดก่อน แล้วจึงแถวที่สอง ไล่ลงไปจนถึงด้านล่าง คุณสมบัติเหล่านี้อธิบายวิธีอ่านพิกเซล

ความกว้างและความสูงเป็นจำนวนเต็มธรรมดา รูปแบบพิกเซลเป็นคุณสมบัติที่น่าสนใจกว่า เพราะกำหนดจำนวนไบต์ที่แต่ละพิกเซลใช้และสิ่งที่ไบต์เหล่านั้นเข้ารหัส ภาพระดับสีเทาเก็บหนึ่งไบต์ต่อพิกเซลซึ่งเก็บค่าความสว่าง ภาพ RGB565 เก็บสองไบต์ต่อพิกเซลซึ่งบรรจุฟิลด์สีแดง สีเขียว และสีน้ำเงินในคำ 16 บิต ภาพ Bayer เก็บหนึ่งไบต์ต่อพิกเซล แต่พิกเซลแต่ละตัวถูกสุ่มตัวอย่างผ่านตัวกรองสีหนึ่งในสามตัวที่เลือกตามตำแหน่งในโมเสก Vision Sensors ได้แจกแจงรายการทั้งหมดไว้แล้ว สิ่งที่สำคัญที่นี่คือทุก Image มีรูปแบบเหล่านี้ชุดเดียว และการเลือกนั้นกำหนดค่าคำนวณไบต์ต่อพิกเซลและความหมายของไบต์ใดๆ ในบัฟเฟอร์

เมื่อมีพอยน์เตอร์ไปยังบัฟเฟอร์ ความกว้าง ความสูง และรูปแบบ คุณสมบัติอื่นๆ ที่อัลกอริทึมอาจต้องการก็คำนวณได้ง่าย ไบต์ที่เริ่มต้นพิกเซล (x, y) อยู่ที่ offset (y * width + x) * bytes_per_pixel จากจุดเริ่มต้นของบัฟเฟอร์ จำนวนไบต์รวมคือ width * height * bytes_per_pixel ที่อยู่ของแถวถัดไปอยู่ห่างออกไป width * bytes_per_pixel ไบต์จากจุดเริ่มต้นของแถวปัจจุบัน Image เปิดเผยสามคุณสมบัตินั้นผ่านการเรียกเมธอดธรรมดา -- width(), height(), format() -- รวมถึงค่า size ที่ได้มาผ่าน size() เมธอดอื่นในโมดูลใช้ค่าเหล่านั้นในการคำนวณ offset เอง โค้ดแอปพลิเคชันแทบไม่ต้องทำเอง

A box labelled image.Image -- Python wrapper at the top, with an arrow pointing down labelled "references" to two stacked boxes -- a thin header box holding width, height, and pixel format, and a thicker pixel buffer box with a row of small cells representing individual pixels. A caption below notes that the buffer lives on the heap by default and in the frame buffer when copy_to_fb is true.

Image คือ Python wrapper ขนาดเล็กที่ชี้ไปยังบล็อกหน่วยความจำต่อเนื่อง ประกอบด้วยส่วนหัวที่เก็บ width, height และ pixel format ตามด้วยบัฟเฟอร์พิกเซล

5.1.2. บัฟเฟอร์มาจากไหน

เรื่องราวเริ่มต้นตลอดบทนี้คือสิ่งที่ Vision Sensors ได้กล่าวถึงแล้ว นั่นคือ เฟรมที่บันทึกมาจาก snapshot ไบต์อยู่ใน frame buffer ของกล้อง และ Image ที่ส่งกลับชี้ไปที่ไบต์เหล่านั้น มีสามวิธีอื่นในการรับ Image ที่พบบ่อย และแต่ละวิธีหมายถึงบัฟเฟอร์จะอยู่ที่ไหน

การโหลดจากไฟล์ทำได้โดยส่ง path ไปยัง constructor: image.Image("/sdcard/saved.jpg") โมดูลจะอ่านไฟล์เข้าสู่บัฟเฟอร์ที่จัดสรรใหม่บน Python heap ไฟล์ BMP, PGM และ PPM จะถูก decode ระหว่างการโหลดและ Image ที่ได้จะมีรูปแบบพิกเซลที่ไม่บีบอัด ไฟล์ JPEG และ PNG จะยังคงถูกบีบอัดไว้ -- Image จะมีรูปแบบ JPEG หรือ PNG และบัฟเฟอร์เก็บ byte stream ของไฟล์โดยแทบไม่เปลี่ยนแปลง หากต้องการทำงานกับพิกเซลของภาพที่บีบอัด แอปพลิเคชันต้องแปลงก่อนผ่าน to_rgb565() หรือ to_grayscale() และการแปลงนั้นคือจุดที่ decompression -- และการพองของ heap ที่ตามมา เช่น JPEG 30 KB อาจกลายเป็น RGB565 600 KB -- เกิดขึ้นจริง การโหลดจากไฟล์มีประโยชน์สูงสุดในระหว่างการพัฒนา เมื่ออัลกอริทึมต้องการทดสอบกับเฟรมอ้างอิงที่รู้จักซึ่งเก็บไว้พร้อมกับสคริปต์

การสร้างจากศูนย์คือกรณี canvas: image.Image(320, 240, image.RGB565) ขอให้โมดูลจัดสรรไบต์ตามจำนวนในรูปแบบนั้น ล้างเนื้อหาให้เป็นศูนย์ และส่ง wrapper กลับมา พิกเซลยังไม่มีความหมายใดๆ -- ทั้งหมดเป็นศูนย์ -- แต่ภาพว่างเป็นเครื่องมือหลักสำหรับรูปแบบที่ใช้ซ้ำบางอย่าง ได้แก่ เฟรมอ้างอิงที่ใช้ลบออกจากเฟรมปัจจุบัน canvas ที่ใช้วาด overlay กราฟิก บัฟเฟอร์ไบนารีที่เติมค่าและใช้เป็น mask

การสร้างจาก ndarray เชื่อมต่อในทิศทางตรงข้าม จากการคำนวณตัวเลขใดๆ กลับเข้าสู่โมดูล image การส่ง ulab.numpy.ndarray แบบ float32 ไปยัง constructor จะสร้าง Image ที่มีมิติตรงกับ ndarray -- รูปร่างสองแกน (h, w) กลายเป็นภาพระดับสีเทา รูปร่างสามแกน (h, w, 3) กลายเป็น RGB565 -- โดยค่า float จะถูก scale จาก 0.0 -- 255.0 เป็นช่วงพิกเซลจำนวนเต็ม heat map จากโครงข่ายประสาทเทียม อาร์เรย์ตัวเลขทุกชนิด สิ่งที่สร้างโดย ml หรือ ulab ล้วนกลายเป็นสิ่งที่ฝั่งการวาดและการตรวจสอบของโมดูล image สามารถใช้ได้

แหล่งที่มาทั้งสี่ส่งกลับ Image ประเภทเดียวกัน โค้ดที่ใช้ออบเจกต์ที่ได้รับไม่จำเป็นต้องติดตามว่ามาจากไหน

5.1.3. มุมมองสองแบบบนไบต์

ส่วนใหญ่โค้ดแอปพลิเคชันจะจัดการ Image เหมือนออบเจกต์ภาพแบบ typed -- สิ่งที่มีเมธอดที่มีชื่อ อีกครึ่งหนึ่งของเรื่องราวคือออบเจกต์เดียวกันนั้นยังปรากฏอย่างโปร่งใสเป็นลำดับไบต์แบบแบนสำหรับ API MicroPython ใดๆ ที่รับอาร์กิวเมนต์ bytes ไบต์เหล่านั้นไม่ใช่สำเนาของบัฟเฟอร์ แต่เป็นมุมมองโดยตรงของมัน

การจัดเตรียมนั้นทำให้การส่งเฟรมที่บันทึกออกจากกล้องเป็นแบบ one-liner ไม่ว่าจะเป็นการ hash การส่งผ่าน serial port หรือการส่งต่อไปยัง network socket -- ไม่มีอันใดต้องการขั้นตอน "แปลง image เป็น bytes" แยกต่างหาก:

import csi
import hashlib

csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)

img = csi0.snapshot()
uart.write(img)              # transmits the raw pixel bytes
hashlib.sha256(img)          # hashes the same bytes
sock.send(img)               # sends them over a socket

มุมมองแบบ bytes-like เป็น read-only โดยค่าเริ่มต้น โดยตั้งใจ บัฟเฟอร์ภาพมีขนาดใหญ่และบางครั้งแชร์กันระหว่างเลเยอร์ของ imaging stack ดังนั้นการให้ buf[0] = 0 ที่ไหนสักแห่งลึกใน call stack สามารถทำให้เสียหายโดยเงียบๆ จึงเป็นขอบที่คมเกินไปที่จะปล่อยทิ้งไว้ เมื่อสิ่งที่แอปพลิเคชันต้องการจริงๆ คือการเข้าถึงระดับไบต์แบบ read-write -- เช่น การเขียนค่าการ calibration เข้าสู่ offset ที่รู้จัก -- bytearray() จะส่งกลับมุมมองแบบ read-write แยกต่างหากอย่างชัดเจนบนหน่วยความจำเดียวกัน ซึ่งบ่งบอกความตั้งใจที่ call site

5.1.4. บัฟเฟอร์อยู่ที่ไหน

บัฟเฟอร์พิกเซลมีขนาดใหญ่พอที่ตำแหน่งใน RAM จะมีความสำคัญ เฟรม RGB565 ขนาด QQVGA มี 160 × 120 × 2 = 38,400 ไบต์ เฟรม RGB565 ขนาด VGA มี 614,400 ไบต์ และ input RGB565 ขนาด 224 × 224 ที่ classifier โครงข่ายประสาทเทียมอาจใช้มีประมาณ 100 KB Python heap บนกล้องขนาดเล็กที่สุดอาจมีเพียงหลายสิบกิโลไบต์หลังจาก runtime บูตแล้ว การเก็บข้อมูลภาพมากกว่าหนึ่งหรือสองเฟรมบน heap จะดันสิ่งอื่นๆ ออกไป

ทางออกคือบัฟเฟอร์พิกเซลส่วนใหญ่ไม่ได้อยู่บน Python heap แต่อยู่ในพื้นที่เฉพาะของ RAM ที่ Vision Sensors แนะนำในชื่อ frame buffer -- หน่วยความจำเดียวกับที่กล้อง DMA เขียนเฟรมที่บันทึกเข้าไปและ IDE preview อ่านเฟรมที่เสร็จแล้วออกมา การดำเนินการส่วนใหญ่บน Image จะแก้ไขแหล่งที่มาในตำแหน่งเดิม นั่นคืออัลกอริทึมอ่านพิกเซล ตัดสิน เขียนค่าใหม่กลับ และไม่มีการจัดสรรผลลัพธ์ภาพแยกต่างหาก การดำเนินการที่ สร้าง ผลลัพธ์แยกต่างหาก -- การแปลงรูปแบบและอื่นๆ อีกหน่อย -- สามารถขอให้วางผลลัพธ์ใน frame buffer ผ่านอาร์กิวเมนต์ keyword copy_to_fb copy_to_fb=True ทำสองสิ่งพร้อมกัน: วางภาพผลลัพธ์ใน frame buffer แทนที่จะเป็น heap (หลีกเลี่ยงแรงดัน heap) และทำให้ผลลัพธ์เป็นเฟรมถัดไปที่ IDE preview จะแสดง การเพิ่ม copy_to_fb=True ที่ขั้นตอนสุดท้ายของ pipeline ดูผลลัพธ์ปรากฏบนหน้าจอ และปรับปรุงซ้ำจากนั้น คือหนึ่งใน debugging idiom ที่มีประโยชน์ที่สุดในการประมวลผลภาพ

ด้วย wrapper ที่ถือบัฟเฟอร์ที่มีป้ายกำกับ สี่วิธีในการสร้าง Image มุมมองสองแบบบนไบต์ และสวิตช์ที่กำหนดว่าอันใหม่จะอยู่ที่ไหน Image จึงไม่ใช่ปริศนาอีกต่อไป คำถามพื้นฐานที่เหลืออยู่ -- วิธีตั้งชื่อตำแหน่งพิกเซล สิ่งที่พิกเซลแต่ละตัวมีจริงๆ วิธีจำกัดการดำเนินการให้อยู่ในส่วนหนึ่งของภาพ -- ล้วนสร้างอยู่บนสิ่งนี้