本文參考資料:
[1] (Strongly Recommend!) Fundamentals and Experiments of Line Scan Camera: http://www.elm-chan.org/works/lcam/report.html
[2] 線陣 CCD 的使用方法(以 TCD1304 為例): https://zzi.io/?p=1091
工程地址:https://github.com/divertingPan/Line_Scan_Camera
原文地址:https://divertingpan.github.io/post/line_scan_camera
前言
Overview
這篇是接續【硬核攝影】給火車拍個全身照和光流法應用——自適應檢測視訊火車速度的內容。但是實則整個工程和前作關係又不是那麼密切,只能算是精神續作。
實際上,這篇2.0的內容是之前做軟體層面的視訊掃描程式碼的精神鼻祖。這個掃描相機的原專案[1]是2011年左右設計的,老潘在大概2018年看到的這個並且嘗試復現(失敗),但一直對這個專案留有念想。因為已經大致瞭解了原理,所以就用視訊錄影做了這個相機的模擬版。結果在2021年的時候突然得知,國內PCB廠商居然開始免費打樣,於是老潘決定重啟這個專案,告別繁雜的飛線,拆解原來的洞洞板,直接上PCB。
老潘在原來的設計基礎上做了一些小改動。本篇主要是為了記錄復現過程中趟過的無數大小坑,以及對本專案的改進的一些指南。
本專案完全開源在github上,包括電路,PCB,硬體程式碼,各種資料等。
老潘不是專業搞硬體開發的,所以肯定很多地方說不明白,希望各位能給予指導或者糾正。
一些效果展示:
這個是手持平移掃過桌上的靜物,因為手抖所以會有變形。
這是架在路邊拍攝,因為光圈開到最大,對焦在中央車道,近景就會失焦模糊。
一個完整的掃描結果,沒有調整長寬比例的原始影像。
火車雖遲但到,可惜這個相機想要準確取景對焦十分考驗手感,導致出片率不高。
線性 vs 二維
這種一維相機也能拍照的原理在前面篇章裡已經介紹了,如果理解了線性掃描的原理,這個相機的原理是一模一樣的。只是利用線性的CCD直接從拍攝(或者說錄製視訊)這個地方就已經做好了固定位置-連續取幀-逐幀拼接成圖這麼個過程。
這時候會有人問:既然能用錄影機直接錄影之後用軟體拼圖,那毫無必要用線性CCD來做這個?這時候我們應該考慮一下兩種方法的優劣,來選擇到底用哪種方法。
- 線性CCD器件的解析度可以輕鬆做到10000×n,即CCD的單幀覆蓋畫素可以做到很高,這點在當前的二維感測器上很難實現(即使強如GFX 100可以做到最長邊11648×8736解析度,但是成本爆炸,而且還會帶來第二點問題)
- 線性CCD只有1維的資料,在高速採集中對外圍電路要求更低(GFX 100和一個線性CCD,同時設計每秒1000幀的取樣速度,難度差異顯而易見)。有人會說:二維器件的取樣率限制可以利用二維平面彌補(即,11648×8736×1幀,採集影像範圍等同於11648×1×8736幀),但是前文已經實驗證明,利用視訊的窄窗來模擬線性掃描的前提是,開窗的尺寸不能太大,否則會出現透視效應。因此,二維CMOS或者CCD器件會有大量資料浪費。
- 承接上條,利用線性CCD可以節約儲存空間和IO開銷。
- 使用CCD的缺點是,由於存在取樣率上限,因此速度超過幀速率上限的物體會發生形變,且丟失的幀細節無法彌補,通過二維器件的錄影和後處理,可以利用窄窗彌補。(這裡的速度-幀率-窗尺寸關係在前作也有推導,線性CCD設定窗寬度固定為1即可)
- 儘管如此,窄窗仍然會帶來以下問題,且很難通過簡單的後期手法修正:
- 開窗的窗寬度和目標運動速度緊密相關,並且分正負。
- 帶有透視的物體,無法統一開窗寬度。
- 對於變速的物體,對開窗寬度非常敏感。
- 物體受到固定位置的光影反射會產生條紋干擾。
如圖所示:
而以上問題在窗寬度等於1畫素,即直接使用線性CCD捕捉影像時,可以消除這些缺陷。例如下圖所示,拍攝的汽車是屬於雙向車道的,被白色橫線擋住的大卡車是自右向左行駛的,白線前面的車是自左向右行駛的。但是由於每幀畫素寬度為1,因此幀排列順序不會出現上圖問題1的效應,只會影響到物體的映象翻轉與否(注意卡車上的字)。
至於物體發生的拉伸形變,可以通過ps縮放簡單修正。將運動速度慢而造成影像拉長的物體可以壓回正常尺寸,沒有資訊損失;而運動過快造成的影像縮短,僅使用插值法拉回原本比例則會帶來資訊損失。
感測器簡介
基本原理
整個專案最重要的部分就是感測器了,這裡使用的是TCD32D線性單色感測器,具有1024個畫素單元,最高捕捉速度可以達到每秒大約2000幀。其實目前的科技已經有最多一萬個像元,可以達到更高的解析度,還支援RGB彩色模式,不過這些東西的原理基本都相通。想要TCD132D輸出東西,首先需要給它一些訊號,如圖所示:
這裡SH是控制CCD採集光訊號,控制積累由光強度轉化成的電訊號(即積分)所用的時間長度,遇到一個SH下降沿就使得CCD開始把目前積累的電訊號往外搬運。因此可以發現,幀率越大,給每個像元的積分時間就越短,相當於感光度變低。在此同時,\phiϕCCD控制搬運的節奏,這個訊號變一次,就讓下一個畫素的訊號出來,直到走完所有的像元。但是這上面並不是所有像元都能捕捉到光訊號,只有中間部分的1024個可以,其他的只會打醬油。\phiϕM是總時鐘,根據圖裡的比例可以看到,\phiϕCCD變一次,就要對應\phiϕM變4次,就是說\phiϕM的頻率需要是\phiϕCCD的4倍。
而這個感測器能接受的這些訊號的頻率範圍如下表前三行所示。可以看到\phiϕM的頻率確實是\phiϕCCD的4倍。而\phiϕCCD每變一次就會輸出一個資料,即一個\phiϕCCD週期會有2個資料輸出,所以資料速率是\phiϕCCD的兩倍。一幀有1024個資料,每秒2M個資料即每秒2k個幀,所以這個感測器的極速就是每秒2千幀左右。
這個TCD132D輸出的是模擬訊號(一個連續區間的電壓值),所以需要一個ADC來把電壓值轉化到0-255之間的數碼值。這也就是說,我們需要一個能夠支援每秒轉化2M個資料的ADC才行。ADC1173可以達到15MHz。而驅動他的方法也很簡單,使用一個時鐘,在每次時鐘下降沿的時候就會把當前的資料採集轉化。
Arduino相關實驗
老潘早些時候用arduino嘗試著去驅動TCD132D以及ADC1173,奈何沒有示波器,沒法檢視輸出訊號是不是符合期望值,arduino mega上面又不帶DMC,資料來不及依次捕捉下來。而且對於如何同步列與列之間的資料,我也沒什麼頭緒。這裡示範一下使用arduino+ADC1173來把這個CCD當做光線感測器使用的一個例子吧(無奈)。
好在mega上面有很多定時器可以使用,這些定時器被我拿來當做各個時鐘了。配置定時器又涉及到了暫存器操作,我的淺見是暫存器就是一堆功能按鈕,按下他就會產生相應的功能,這些功能排列組合出來就成了神奇或者詭異的執行姿態。。。使用對應的暫存器的方法就是通過設定某個變數的名字(一般用到的微控制器都會把每個暫存器做好名稱和底層地址的對應檔案給大家)等於一個二進位制的數字,這個二進位制數字的每一位都對應了這個暫存器裡的一個按鈕,1就是按下,0就是不按。通過紛雜迷人眼的來回切換這些按鈕,這個機器就運轉起來了。
但是一般來說,直接設定某個暫存器就等於某一個數字可能不太妥當。因為有時候我們只想改變這裡面好幾個按鈕中的一個按鈕,不想動其他的按鈕,如果每次都這樣手動的一次設定一大排按鈕的狀態,容易搞錯。所以有些時候可以利用邏輯符號來指定對某一個按鈕做操作。
// 讓DDRB的第0位和第1位變成1 這裡|是按位或 DDRB |= (00000001 | 00000010); // 讓ADCSRA的ADPS0:2變成0 這裡的&是按位與 ADCSRA &= ~((1 << ADPS2) | (1 << ADPS1) | (1 << ADPS0)); // Arduino裡面_BV()的用法等同於設定某個位為1 DDRB和DDB7已經預定義過 DDRB |= _BV(DDB7)
因此arduino裡面對於各部分的時鐘設定,結合說明書裡的指示和網上的例程,可以寫出以下:
void setup() { // read one frame (line) // Init the port to output mode DDRB |= _BV(DDB7) | _BV(DDB5) | _BV(DDB4); DDRE |= _BV(DDE3); DDRH |= _BV(DDH3); // LEDCLK (pin 13)(PB7) TCCR0A = _BV(COM0A1) | _BV(WGM01) | _BV(WGM00); TCCR0B = _BV(CS00); OCR0A = 255; // MCLK (pin 11)(PB5) TCCR1A = _BV(COM1A0); TCCR1B = _BV(WGM12) | _BV(CS10); OCR1A = 1; // 4MHz PORTB |= _BV(PORTB5); // start from HIGH // CCD (pin 10)(PB4) TCCR2A = _BV(COM2A0) | _BV(WGM21); TCCR2B = _BV(CS20); OCR2A = 7; // 1MHz // ADCCLK (pin 5)(PE3) TCCR3A = _BV(COM3A0); TCCR3B = _BV(WGM32) | _BV(CS30); OCR3A = 3; // 2MHz TCNT3 = 3; // SH (pin 6)(PH3) total: 1092x500ns = 546us TCCR4A = _BV(COM4A1) | _BV(WGM41); TCCR4B = _BV(WGM42) | _BV(WGM43) | _BV(CS40); OCR4A = 7; ICR4 = 8735; TCNT4 = 18; }
接線如下(懶得畫詳細的麵包板圖了,就大概看看圖一樂吧)
時序圖如下,不知道為什麼ADC時鐘總是無法對齊,不過只要ADC下降沿在CCD變化的附近即可,這個範圍內的CCD輸出訊號仍然是穩定的。(有待通過示波器考證)
最後通過Arduino採集ADC輸出的值,可以看出一定的響應規律。實驗中發現感測器在亮光時輸出低電位,無光時輸出高電位,和一般數字影像裡的情況剛好反過來。
另外還寫了個用Arduino做呼吸燈的無聊程式碼,就是板上13號口自帶的那個燈。但是這個呼吸燈是用的定時器,以及變化規律是正弦的(感覺還是毫無用處呵)。
void led_blink() { for (float i = 0; i < 5000; i++) { int t = 255 * 0.5 * (1 + sin(i / 5000 * 2 * PI)); OCR0A = t; } }
CCD的老化
如果在很暗的環境下捕獲影像,之後通過調整曲線或者色階將圖片提亮後,有可能會看到這種條紋。根據參考連結[2],這個現象是因為CCD感測器內部的兩個放大器的微小誤差導致的。
事實上,大多數線陣 CCD 為了提高輸出頻率,都具有多個 Shift Register 結構的設計,在這一點上理論與實際的差異可以用於解釋線上陣 CCD 壽命快要結束時,往往得到的訊號會出現奇怪的週期性(偽訊號)的現象。比如說 TCD1304 有兩套 Shift Register,因此在使用很長時間之後,兩個 Shift Register 對應的模擬放大器的老化情況不一致,因此輸出的訊號中,每個偶數畫素的訊號比相鄰的奇數畫素的訊號總是高一些,或者總是低一些)
線性相機實施細節
這一節按照順序講述一下在抄作業的時候可能遇到的各種坑,以免抄作業都抄不好。
說在最前面,這裡的元件用貼片還是接外掛都影響不大。(我自己為了製作方便,能用直外掛都用了,實際試驗沒發現太大問題)
顯示部分替換為SSD1306模組
原作者所用的各種元件,我在當時基本都能集齊,並且花費不是太多。唯獨他所用的OLED螢幕完全找不到。所以這部分乾脆就直接用淘寶白菜價的OLED模組就行。經過一番研究,這裡是用了4線的SPI和OLED通訊,所以要買那種7個針腳的OLED模組。這樣一來,原來設計裡面的螢幕供電部分也可以去掉了,因為模組上面就帶有供電管理。但是這樣還不夠,因為原作的OLED驅動晶片和淘寶常見的不一樣,所以要改程式碼。
好在店家當時給了SSD1306的例程,並且改的地方比較簡單,只是改一下初始化引數。在原作的disp.c裡面的233行是這樣的:
static const BYTE ini[] = { /* Initialization parameters for UG-2832ASWAG or UG-2864ASWAG */ 0xDB, 0x3F, /* Vcom level */ 0xD9, 0x1F, /* Pre/Dis-charge period */ 0xA1, /* Column direction (L/R inverted) */ 0xC8, /* COM direction (U/D inverted) */ 0xDA, 0x12, /* COM scan alt mode */ 0xA8, 0x3F, /* Mux ratio (2832:1F, 2864:3F) */ 0xD5, 0xF0, /* Clock */ 0x81, 0x64, /* Contrast (2832:0x14, 2864:0x64) */ 0xD3, 0x00, /* Display offset (0) */ 0xAD, 0x8A, /* Internal DC-DC (off) */ 0xA6, /* Display invert mode (normal) */ 0xA4, /* Entire display (0) */ 0x40 /* Display start line (0) */ };
如果把這裡的初始化引數改成SSD1306的,並且按照我修改的電路圖中的連線方式,直接插上模組就可以正常使用了:
static const BYTE ini[] = { /* Initialization parameters for SSD1306 */ 0xAE,//--turn off oled panel 0x00,//--set low column address 0x10,//--set high column address 0x40,//--set start line address Set Mapping RAM Display Start Line (0x00~0x3F) 0x81,//--set contrast control register 0xCF,//--Set SEG Output Current Brightness 0xA1,//--Set SEG/Column Mapping 0xa0: horizonal reverse 0xa1: none 0xC8,//--Set COM/Row Scan Direction 0xc0: vertical reverse 0xc8: none 0xA6,//--set normal display 0xA8,//--set multiplex ratio(1 to 64) 0x3f,//--1/64 duty 0xD3,//--set display offset Shift Mapping RAM Counter (0x00~0x3F) 0x00,//--not offset 0xd5,//--set display clock divide ratio/oscillator frequency 0x80,//--set divide ratio, Set Clock as 100 Frames/Sec 0xD9,//--set pre-charge period 0xF1,//--Set Pre-Charge as 15 Clocks & Discharge as 1 Clock 0xDA,//--set com pins hardware configuration 0x12, 0xDB,//--set vcomh 0x40,//--Set VCOM Deselect Level 0x20,//--Set Page Addressing Mode (0x00/0x01/0x02) 0x02,// 0x8D,//--set Charge Pump enable/disable 0x14,//--set(0x10) disable 0xA4,//--Disable Entire Display On (0xa4/0xa5) 0xA6,//--Disable Inverse Display On (0xa6/a7) 0xAF //--turn on oled panel };
但是這裡仍然有一些小問題,用這個方法螢幕雖然能顯示,但是有2畫素的偏移。注意看左上角的一個],其實那是右上角的電池標誌的最右邊的框。雖然這個無傷大雅,但是我不知道如何修正這個bug。
程式碼編譯
作者給了一個Makefile檔案,因此只要配置好編譯環境,進入原始檔路徑直接執行make命令即可。但是有一個小細節是,Linux環境裡必須先配置好arm-linux-eabi-gcc編譯器(可以直接使用sudo apt-get install gcc-arm-none-eabi
,和apt-get install lsb-core
)才可以順利的make。最終編譯成功會在路徑下生成一個obj資料夾,裡面會有一個hex檔案,終端裡會顯示這樣。
hex下載進主控
這裡出了大問題,原本我死活連不上這個微控制器,一度以為是焊接的時候燒壞了晶片(好巧不巧的是我從原來的電路拆這個晶片,以及往新板上裝這個晶片的時候都搞了半天才弄好)。結果看了一下多年前自己留下的記錄才知道,下載的時候需要給晶片復位並且拉低某個引腳,進入ISP模式才行。具體操作是:將P2.10(ISP)置為低,同時讓reset為低,然後先放開RST,再放開ISP,以載入BootLoader,不然flash magic識別不了晶片(LOW on this pin while RESET is LOW forces on-chip bootloader to take over control of the part after a reset.)
。判斷方法是:使用flash magic,如果點選ISP-Read Device Signature能夠出裝置的ID,那就是成功連上了。
對於兩個引腳的操作,需要自己手動用插線去接插這兩個針腳到任意的GND上。當時畫PCB的時候沒有留意這部分的操作,當然你也可以拿去圖紙,自己把這部分加兩個按鈕上去。下載器的TX口和RX口要接在主控板上面的RX和TX腳上。
另外,在下載程式時,儘量把另外兩塊板拿掉,我發現在Control Board上插著Analog Board的時候,在下載程式途中總是失敗斷開。有可能是供電不足的問題?
LC4256V
這部分原作者只給了一個abl檔案,需要用ispLEVER classic操作一下,但是我在當年已經做過了操作,並且得到了直接往器件裡面寫的二進位制檔案。這部分程式碼確實是觸及到了我的知識盲區,我實在改不動什麼,所以就沒有再仔細研究。至於下載的方法,首先用lattice下載線,接好線之後需要單獨給主控板通電,下載線是不能供電的。之後利用軟體操作。軟體部分的編譯下載等操作根據說明書操作即可。說明書也在github裡面了。
調零
這裡需要調整變阻器分壓來控制感測器在最暗的環境下輸出的訊號強度,或者理解為零點校準。我們期望在完全無光的環境時影像值為0。如果需要調整曝光,希望在很暗的環境下也輸出有一定亮度的圖片也可以在這裡調整。通過改變這個變阻器的阻值,遮住鏡頭,觀察螢幕上的亮度強度到自己希望的位置即可(正常狀態下,黑暗時螢幕上的光線曲線也在中央虛線位置)。但是要注意先設定演算法層面的增益調為0再調整變阻器。
FS ERROR
開機是必需要插入記憶體卡的,不然會報錯。但是如果你插了記憶體卡還是報錯,檢查你的記憶體卡格式,需要為FAT32才可以。現在的新記憶體卡都比較大,一般都是exFAT格式並且windows系統自帶的右鍵格式化沒法格式化為FAT32,可以用DiskGenius格式化,注意格式化前檢查好資料情況。
最大記錄長度
在lcam.h的第一行就是設定最大記錄長度的引數,當相機的任意按鈕被按下,或者達到最大記錄長度時,記錄停止。原始設定是100000,但是你可以改更大。不過注意,FAT32系統支援的最大檔案大小是4GB。同時,BMP格式檔案頭裡面通過bfSize定義檔案大小,bfSize佔4個位元組,因此支援的最大檔案儲存也是4GB。
BMP影像轉存PNG
當影像超過一定大小後,用普通的照片檢視器甚至是Photoshop都可能無法正常開啟檔案,但是使用Windows自帶的畫圖就可以檢視。不能用PS編輯簡直震怒,但是既然有些軟體能檢視有些沒法檢視,而且PS本身編輯操作很大的圖片都沒有問題,於是猜測可能是因為BMP編碼的問題。因為這個BMP裡面的顏色表資訊是自定義的,也許PS對這方面的支援不是很好。
所以可以把原資料用python先讀進記憶體,然後再轉存成更加通用的PNG格式,既能壓縮體積還能讓PS編輯。程式碼非常簡單。總共就幾行,如下。懶得複製貼上的話github裡也有。
import cv2 image_path = 'Y0023.BMP' image = cv2.imread(image_path) cv2.imwrite('{}_modified.png'.format(image_path[:-4]), image)
光路與鏡頭
首先要確保鏡頭的像場能夠覆蓋CCD的長度。其次要根據所用鏡頭的卡口對應法蘭距來設計機身法蘭盤的外平面到感測器的距離。這裡有一個靈魂手繪尺寸圖,僅供參考。
機身我直接搞了個紙盒來裝電路以及卡鏡頭,但是機身需要密閉不漏光,如果盒子上有漏光的窟窿(尤其是離感測器近的位置)最好補住。關於如何獲得法蘭盤,可以通過低價收廢舊相機拆法蘭盤,或者買最便宜的卡口轉接環來獲得。但是挑轉接環時要注意法蘭盤的公母之分。
影像撕裂問題
如果你用比較舊/雜牌/便宜的記憶體卡,會發現經常出現下面這種情況,
車尾部的斷層說明這裡的資料有卡頓,採集到的資料沒有及時被存下來造成了丟幀。老潘使用過一個撿來的寫速度相當慢的記憶體卡,經常出現這種情況。使用了一個新買的U1速度的記憶體卡,偶爾會出現這種情況。使用一個U3的記憶體卡,極少出現這種情況。
相機操作方法
在原作者的部落格裡面詳細介紹了。此處無必要再次複製貼上。
黑白拍照心得
老潘順便還參悟了一些關於黑白攝影的體驗。很多人說攝影是用光的藝術,尤其在黑白攝影裡面,沒有顏色加持,光線這一點就相當重要。例如這裡的火車,因為是背光所以整個背光區域就顯得非常平,沒有質感,車體的稜條完全沒感覺。但是車頂的布受光很好,顯得起伏明顯,光影變化很豐富。