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 จึงไม่ใช่ปริศนาอีกต่อไป คำถามพื้นฐานที่เหลืออยู่ -- วิธีตั้งชื่อตำแหน่งพิกเซล สิ่งที่พิกเซลแต่ละตัวมีจริงๆ วิธีจำกัดการดำเนินการให้อยู่ในส่วนหนึ่งของภาพ -- ล้วนสร้างอยู่บนสิ่งนี้