大多數人聽到“JPEG解碼”時,通常會覺得這是很困難的事,需要很強的處理能力以及複雜的數學運算,並認為在相對便宜且速度較慢的8位元處理器平臺(比如Arduino)上是不可能實現的,或者說至少是不切實際的。在本文中,我們將學習如何使用基於Arduino控制的相機拍攝JPEG照片,以及如何將照片轉換成圖元點矩陣,並將所有圖元通過序列埠傳輸到我們的PC端或者任何我們想要的平臺上!
雖然說上面描述的內容是完全可以實現的,但是仍然有必要解釋一下為什麼我們在解碼JPEG照片時會遇到麻煩。畢竟,在上面的硬體要求中列有一個SD模組,您會問:“我們直接把照片以photo.jpeg的格式存儲到SD卡裡不就行了嗎?”當然,這確實是整個過程中的重要一步,但是現在請從不同的角度來考慮這個問題:如果我們想通過速度慢、有些不穩定的連接來發送照片怎麼辦?如果我們只是把JPEG照片分割成不同的包並通過慢速連接發送,那麼就有部分資料損壞或丟失的風險。發生這種情況時,我們很可能無法用損壞的資料還原原始資料。
但是,當我們將JPEG解碼為點陣圖,然後發送實際圖元時,不會有任何風險。如果某些資料在傳輸的過程中損壞或丟失,我們仍然可以獲取整張圖像,只有資料損壞的地方會出現失色,錯位或圖元丟失的情況。當然,它與我們的原始圖像並不相同,但是仍然包含了大多數原始資訊,並且仍然是“可讀的”。既然已經知道了為什麼要這樣做,接下來讓我們看一下如何實施這種方法。
在開始解碼JPEG照片之前,首先我們需要拍攝照片。我們最終的目標是拍攝一張照片,將照片存儲到SD卡中,然後發送到某個地方。那我們按照這個思路先從一個簡單的設置開始吧。
因為我們需要大量的RAM來對照片進行解碼,所以我們將使用Arduino Mega。此外,Mega上還有一個額外的有利設計:有四個單獨的硬體序列埠,這樣我們就可以使用Serial1埠與相機進行通信,並使用Serial埠與PC進行通信。
您可能已經注意到了,相機RX線上有一個簡單的電阻分壓器。這是因為VC0706晶片的邏輯電平為3.3V(即使電源電壓為5V),但Arduino Mega的邏輯電平為5V。所以在這裡有個善意忠告:當將5V的Arduino和3.3V模組進行接合時,在RX線上始終至少使用一個分壓器。這比換一個新的模組要快得多。SD卡讀卡器通過SPI介面直接連接。
既然硬體已經設置好了,那我們就需要開始解決代碼部分了。標準Arduino IDE安裝已經包含了用於SD卡的庫,因此我們從列表中對SD卡進行查看即可。
我們需要控制的另一個設備是VC0706攝像頭。控制過程相對簡單,我們只需要使用串列線發送一些指令,然後通過同一條線接收JPEG照片即可。我們可以編寫一個庫來執行此操作,但是因為這一步我們不需要考慮整體草圖的大小,所以我們將使用Adafruit開發的一個VC0706庫。為了拍攝照片並保存到SD卡上,我們將使用以下代碼,代碼是該庫隨附的經過輕微修改的Snapshot示例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
// Include all the libraries #include <Adafruit_VC0706.h> #include <SPI.h> #include <SD.h> // Define Slave Select pin #define SD_CS 53 // Create an instance of Adafruit_VC0706 class // We will use Serial1 for communication with the camera Adafruit_VC0706 cam = Adafruit_VC0706(&Serial1); void setup() { // Begin Serial port for communication with PC Serial.begin(115200); // Start the SD if(!SD.begin(SD_CS)) { // If the SD can't be started, loop forever Serial.println("SD failed or not present!"); while(1); } // Start the camera if(!cam.begin()) { // If the camera can't be started, loop forever Serial.println("Camera failed or not present!"); while(1); } // Set the image size to 640x480 cam.setImageSize(VC0706_640x480); } void loop() { Serial.print("Taking picture in 3 seconds ... "); delay(3000); // Take a picture if(cam.takePicture()) { Serial.println("done!"); } else { Serial.println("failed!"); } // Create a name for the new file in the format IMAGExy.JPG char filename[13]; strcpy(filename, "IMAGE00.JPG"); for(int i = 0; i < 100; i++) { filename[5] = '0' + i/10; filename[6] = '0' + i%10; if(!SD.exists(filename)) { break; } } // Create a file with the name we created above and open it File imgFile = SD.open(filename, FILE_WRITE); // Get the size of the image uint16_t jpglen = cam.frameLength(); Serial.print("Writing "); Serial.print(jpglen, DEC); Serial.print(" bytes into "); Serial.print(filename); Serial.print(" ... "); // Read all the image data while(jpglen > 0) { // Load the JPEG-encoded image data from the camera into a buffer uint8_t *buff; uint8_t bytesToRead = min(32, jpglen); buff = cam.readPicture(bytesToRead); // Write the image data to the file imgFile.write(buff, bytesToRead); jpglen -= bytesToRead; } // Safely close the file imgFile.close(); Serial.println("done!"); delay(3000); } |
在,Arduino將每10秒左右拍攝一張照片,直到SD卡上的空間用完為止。但是,由於照片通常約為48kB,並且我目前使用的是2GB的SD卡,因此足夠容納超過43000張的照片。理論上來說我們不需要那麼多的照片。但是既然已經拍攝了一些照片,我們現在可以繼續進行下一個有趣環節了:將它們從JPEG壓縮後的難以管理的雜亂資料變成簡單的圖元陣列!
在開始解碼前,讓我們快速地看一下圖片資料在JPEG檔中究竟是如何存儲的。如果您對這部分不太感興趣,可以跳過下面三段內容。如果您確切地對圖形和壓縮方面的知識瞭解一二(不像我這樣),您也可以跳過這一部分。以下內容進行了一定程度的簡化。
對任何類型的圖片資料進行存儲時,有兩種基本方法:無損和失真壓縮。兩者的區別很明顯:當使用無失真壓縮(例如PNG)對圖像進行編碼時,處理之後圖像的每個圖元都與開始時完全相同。這非常適合於諸如電腦圖形學之類的工作,但是不幸的是,這是以增加檔大小為代價的。另一方面,對於像JPEG這樣的失真壓縮,我們丟失了一些細節,但是生成的檔大小要小得多。
JPEG壓縮方式在理解上可能會有點困難,因為會涉及到一些“離散余弦變換”,不過主要原理實際上是非常簡單的。首先,將圖片從RGB顏色空間轉換為YCbCr。我們都知道RGB顏色空間—它存儲了紅色(R)、綠色(G)和藍色(B)的顏色值。YCbCr有很大的不同—它使用亮度(Y—基本是原始圖像的灰度圖),藍色差分量(Cb—圖片中的“藍色”)和紅色差分量(Cr—圖片中的“紅色”)。
JPEG減小檔大小的方法實際上與人眼處理顏色的方式密切相關。看一下上圖中的Y、Cb和Cr分量圖。哪一個看起來更像是原始圖片?是的,灰度圖!這是因為人眼對亮度的敏感度要比對其它兩個分量的敏感度高得多。JPEG壓縮就非常聰明地利用了這一點,在保留原始Y分量的同時減少Cb和Cr分量中的信息量。如此一來,生成的圖片就比原始檔小得多,並且由於大多數壓縮資訊都位於人眼不太敏感的分量中,因此與未壓縮的圖片相比,您幾乎看不到壓縮圖片的區別。
現在,讓我們開始運行真正實現將JPEG轉換為圖元陣列的代碼吧。幸運的是,有一個庫可以做到這一點—Bodmer的JPEGDecoder(可在GitHub上獲得),該庫基於Rich Geldreich(也可在GitHub上獲取)提供的出色的picojpeg庫。雖然最初編寫JPEGDecoder的目的是在TFT顯示器上顯示圖像,但是將其進行一些細微調整後就可以用於我們的工作了。
該庫的使用非常簡單:我們輸入JPEG檔,然後該庫就會開始產生圖元陣列—所謂的最小編碼單位,或簡稱為MCU。MCU是一個16×8的區塊。庫中的函數將以16位元顏色值的形式返回每個圖元點的顏色值。高5位是紅色值,中6位是綠色值,低5位是藍色值。現在,我們可以通過任何通信通道來發送這些值。我將使用序列埠,以便之後可以更容易地接收資料。下面的Arduino草圖對一張圖像進行了解碼,然後發送了MCU中每個圖元點的16位元RGB值,並對影像檔中的所有MCU重複該操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
// Include the library #include <JPEGDecoder.h> // Define Slave Select pin #define SD_CS 53 void setup() { // Set pin 13 to output, otherwise SPI might hang pinMode(13, OUTPUT); // Begin Serial port for communication with PC Serial.begin(115200); // Start the SD if(!SD.begin(SD_CS)) { // If the SD can't be started, loop forever Serial.println("SD failed or not present!"); while(1); } // Open the root directory File root = SD.open("/"); // Wait for the PC to signal while(!Serial.available()); // Send all files on the SD card while(true) { // Open the next file File jpgFile = root.openNextFile(); // We have sent all files if(!jpgFile) { break; } // Decode the JPEG file JpegDec.decodeSdFile(jpgFile); // Create a buffer for the packet char dataBuff[240]; // Fill the buffer with zeros initBuff(dataBuff); // Create a header packet with info about the image String header = "$ITHDR,"; header += JpegDec.width; header += ","; header += JpegDec.height; header += ","; header += JpegDec.MCUSPerRow; header += ","; header += JpegDec.MCUSPerCol; header += ","; header += jpgFile.name(); header += ","; header.toCharArray(dataBuff, 240); // Send the header packet for(int j=0; j<240; j++) { Serial.write(dataBuff[j]); } // Pointer to the current pixel uint16_t *pImg; // Color of the current pixel uint16_t color; // Create a data packet with the actual pixel colors strcpy(dataBuff, "$ITDAT"); uint8_t i = 6; // Repeat for all MCUs in the image while(JpegDec.read()) { // Save pointer the current pixel pImg = JpegDec.pImage; // Get the coordinates of the MCU we are currently processing int mcuXCoord = JpegDec.MCUx; int mcuYCoord = JpegDec.MCUy; // Get the number of pixels in the current MCU uint32_t mcuPixels = JpegDec.MCUWidth * JpegDec.MCUHeight; // Repeat for all pixels in the current MCU while(mcuPixels--) { // Read the color of the pixel as 16-bit integer color = *pImg++; // Split it into two 8-bit integers dataBuff[i] = color >> 8; dataBuff[i+1] = color; i += 2; // If the packet is full, send it if(i == 240) { for(int j=0; j<240; j++) { Serial.write(dataBuff[j]); } i = 6; } // If we reach the end of the image, send a packet if((mcuXCoord == JpegDec.MCUSPerRow - 1) && (mcuYCoord == JpegDec.MCUSPerCol - 1) && (mcuPixels == 1)) { // Send the pixel values for(int j=0; j<i; j++) { Serial.write(dataBuff[j]); } // Fill the rest of the packet with zeros for(int k=i; k<240; k++) { Serial.write(0); } } } } } // Safely close the root directory root.close(); } // Function to fill the packet buffer with zeros void initBuff(char* buff) { for(int i = 0; i < 240; i++) { buff[i] = 0; } } void loop() { // Nothing here // We don't need to send the same images over and over again } |
注釋中已經對大多數代碼進行了解釋,但是我還是需要對代碼結構中的“包”進行一些說明。為了使資料傳輸更加有序,所有內容都以包的形式傳輸,最大長度為240位元組。包有兩種可能的類型:
乍一看,包的長度似乎是隨機的。但是為什麼恰好是240個位元組?為什麼不是256個,使我們可以在每個包中發送兩個MCU呢?這是另一個我們日後將會解決的謎團,但是我們可以保證, 數字240不會有任何隨機性。這裡有個小提示:如果包中有256個位元組的資料,我們要在哪裡存儲源位址和目標位址呢?
現在,我們有了一個可以解碼和發送圖片檔的代碼,但是仍然缺少一個核心功能:目前為止,並沒有可以回應這些資料的另一埠。這意味著是時候再次啟用Processing了!
我在Arduino六足機器人第三部分:遠程控制 中曾介紹過一些有關Processing的內容,用其編寫了一個應用程式,通過該應用程式我們能夠輕鬆控制六足機器人。簡單回顧一下:Processing是一種基於Java的語言,主要用於繪圖工作。因此它非常適用於我們現在要做的圖元顯示的工作!該程式就是用Processing實現的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
// Import the library import processing.serial.*; Serial port; void setup() { // Set the default window size to 200 by 200 pixels size(200, 200); // Set the background to grey background(#888888); // Set as high framerate as we can frameRate(1000000); // Start the COM port communication // You will have to replace "COM30" with the Arduino COM port number port = new Serial(this, "COM30", 115200); // Read 240 bytes at a time port.buffer(240); } // String to save the trimmed input String trimmed; // Buffer to save data incoming from Serial port byte[] byteBuffer = new byte[240]; // The coordinate variables int x, y, mcuX, mcuY; // A variable to measure how long it takes to receive the image long startTime; // A variable to save the current time long currentTime; // Flag to signal end of transmission boolean received = false; // Flag to signal reception of header packet boolean headerRead = false; // The color of the current pixel int inColor, r, g, b; // Image information variables int jpegWidth, jpegHeight, jpegMCUSPerRow, jpegMCUSPerCol, mcuWidth, mcuHeight, mcuPixels; // This function will be called every time any key is pressed void keyPressed() { // Send something to Arduino to signal the start port.write('s'); } // This function will be called every time the Serial port receives 240 bytes void serialEvent(Serial port) { // Read the data into buffer port.readBytes(byteBuffer); // Make a String out of the buffer String inString = new String(byteBuffer); // Detect the packet type if(inString.indexOf("$ITHDR") == 0) { // Header packet // Remove all whitespace characters trimmed = inString.trim(); // Split the header by comma String[] list = split(trimmed, ','); // Check for completeness if(list.length != 7) { println("Incomplete header, terminated"); while(true); } else { // Parse the image information jpegWidth = Integer.parseInt(list[1]); jpegHeight = Integer.parseInt(list[2]); jpegMCUSPerRow = Integer.parseInt(list[3]); jpegMCUSPerCol = Integer.parseInt(list[4]); // Print the info to console println("Filename: " + list[5]); println("Parsed JPEG width: " + jpegWidth); println("Parsed JPEG height: " + jpegHeight); println("Parsed JPEG MCUs/row: " + jpegMCUSPerRow); println("Parsed JPEG MCUs/column: " + jpegMCUSPerCol); // Start the timer startTime = millis(); } // Set the window size according to the received information surface.setSize(jpegWidth, jpegHeight); // Get the MCU information mcuWidth = jpegWidth / jpegMCUSPerRow; mcuHeight = jpegHeight / jpegMCUSPerCol; mcuPixels = mcuWidth * mcuHeight; } else if(inString.indexOf("$ITDAT") == 0) { // Data packet // Repeat for every two bytes received for(int i = 6; i < 240; i += 2) { // Combine two 8-bit values into a single 16-bit color inColor = ((byteBuffer[i] & 0xFF) << 8) | (byteBuffer[i+1] & 0xFF); // Convert 16-bit color into RGB values r = ((inColor & 0xF800) >> 11) * 8; g = ((inColor & 0x07E0) >> 5) * 4; b = ((inColor & 0x001F) >> 0) * 8; // Paint the current pixel with that color set(x + mcuWidth*mcuX, y + mcuHeight*mcuY, color(r, g, b)); // Move onto the next pixel x++; if(x == mcuWidth) { // MCU row is complete, move onto the next one x = 0; y++; } if(y == mcuHeight) { // MCU is complete, move onto the next one x = 0; y = 0; mcuX++; } if(mcuX == jpegMCUSPerRow) { // Line of MCUs is complete, move onto the next one x = 0; y = 0; mcuX = 0; mcuY++; } if(mcuY == jpegMCUSPerCol) { // The entire image is complete received = true; } } } } void draw() { // If we received a full image, start the whole process again if(received) { // Reset coordinates x = 0; y = 0; mcuX = 0; mcuY = 0; // Reset the flag received = false; // Measure how long the whole thing took long timeTook = millis() - startTime; println("Image receiving took: " + timeTook + " ms"); println(); } } |
當您在連接Arduino之後運行該程式,然後按下鍵盤上的任意鍵時,您(希望)會看到暗淡、單一的灰色背景逐漸被最初存儲在SD卡上的圖像所取代。由於替換是逐圖元進行的,因此整個過程具有一種老式撥號數據機的載入圖像風格!
雖然我們以相當高的串列傳輸速率(準確值為115200)運行序列埠,接收一張圖像也需要大約60秒。我們可以用它來計算實際的傳送速率。
原始圖像寬640圖元,高480圖元,總計307200圖元。每個圖元都由2位元組的顏色值來表示,總共要傳輸614400個位元組(即600KB)。那麼我們的最終速度約為10kB/s。對於我們制定的“協議”來說,這並不算很糟糕,不是嗎?此外,它還向您展示了為什麼圖像壓縮如此有用。原始JPEG檔只有48kB左右,而解碼後的點陣圖則需要600kB。如果我們要傳輸JPEG檔,即使使用非常簡單的“協議”,也可以在5秒之內完成傳輸。當然,萬一傳輸失敗,我們將可能無法追回任何資料—這種情況現在已經不會發生了。
最後,我們證實了本文開頭所說的:在Arduino上處理圖像是可能的,並且在某些情況下可能會更有優勢。現在,我們可以使用串列相機拍攝照片,對其進行解碼,通過序列埠發送,然後在另一端接收了!可以將本文作為您在Arduino上進行影像處理的入門簡介。
像往常一樣,有很多方面都可以進一步改善。一個需要添加的主要功能可能是使用AES對我們的消息進行加密,這一點很容易實現(即使在Arduino上)。在Arduino上,安全性通常會被忽視,這是很危險的,因此在下一個項目中我們可能會將重點更多地放在安全性上。
感謝您閱讀本文!請繼續關注我們的其他有趣項目!也許有些項目將會使用到我們在本項目中所學到的所有內容!