Linux 的應用(刊載於 PC2000 雜誌十一月號)-- Video Streaming 探討 (5)
本期將以完整的程式範例為主, 說明之前未深入說明的地方。並且更詳細地介紹video4linux 如何以 mmap (filp-flop) 方式擷取影像資料, 同時也會展示如何將擷取出來的影像存成圖檔, 並且利用繪圖軟體開啟。
作者: 陳俊宏
www.jollen.org
mmap 的初始化從那裡開始
繼前四期介紹有關 Video Streaming 的內容後, 最近收到幾位讀者的來信, 詢問有關 video4linux 利用 mmap 擷取影像的方法。video4linux 以 mmap 擷取影像的方法在本文第 4 篇曾經簡單介紹過, 但是有讀者希望可以做更詳細的介紹, 因此筆者特別將相關的程式碼完整列出供參考。
要提到 mmap 的初始化, 我們要配合第 2 篇文章的程式範例。底下是對影像擷取裝置做初始化的程式碼, 與第 2 篇文章的範例比較, 底下的函數設計的更完整:
int device_init(char *dev, int channel, int norm)
{
int i;
if (dev == NULL) {
dev = "/dev/video0"; //set to default device
}
if (v4l_open(dev, &vd)) {
return -1;
} else {
v4l_grab_init(&vd, screen_width, screen_height); //wake up drivers!
v4l_close(&vd);
}
if (v4l_open(dev, &vd)) return -1;
if (v4l_get_channels(&vd)) return -1;
if (v4l_set_norm(&vd, norm)) return -1;
if (v4l_mmap_init(&vd)) return -1;
if (v4l_switch_channel(&vd, channel)) return -1;
printf("%s: initialization OK... %s\n"
"%d channels\n"
"%d audios\n\n", dev, vd.capability.name, vd.capability.channels, vd.capability.audios);
for (i = 0; i < vd.capability.channels; i++) {
printf("Channel %d: %s (%s)\n", i, vd.channel[i].name,
v4l_norms[vd.channel[i].norm].name);
}
printf("v4l: mmap's address = %p\n", vd.map);
printf("v4l: mmap's buffer size = 0x%x\n", vd.mbuf.size);
printf("v4l: mmap's frames = %d (%d max)\n", vd.mbuf.frames, VIDEO_MAX_FRAME);
for (i = 0; i < vd.mbuf.frames; i++) {
printf("v4l: frames %d's offset = 0x%x\n", i, vd.mbuf.offsets[i]);
}
printf("v4l: channel switch to %d (%s)\n", channel, vd.channel[channel].name);
// start initialize grab
if (v4l_get_picture(&vd)) return -1;
if (v4l_set_palette(&vd, DEFAULT_PALETTE)) return -1;
if (v4l_grab_init(&vd, screen_width, screen_height)) return -1;
if (v4l_grab_sync(&vd)) return -1;
return 0;
}
我們又把 device_init() 寫的更完整了。粗體字的地方是我們初始化 mmap 的程式碼, 一開始的程式可能又讓人覺得一臉汒然:
if (v4l_open(dev, &vd)) {
return -1;
} else {
v4l_grab_init(&vd, screen_width, screen_height); //wake up drivers!
v4l_close(&vd);
}
將 device 開啟成功後, 做了一次 v4l_grab_init 後再把 device 關掉, 用意何在呢? 其實, 是因為 bttv 的 driver 是以 module 的方式安裝到 Linux kernel, 所以 bttv driver 會因為沒有被使用, 而「睡覺了」。
我們加上一次 v4l_grab_init() 的目的就是為了要「叫醒」bttv 的 driver, 其實這個動作可有可無, 但一般認為加上會比較好。
v4l_mmap_init() 是對 mmap 做初始化的工作, 不過要特別注意, 這個動作要在 channel 與 norm 都設定好後才進行, 底下會再說明一次。
v4l_mmap_init() 相當重要, 因為我們要利用 mmap() 函數將 v4l_deivce 結構裡的 map「連接」起來。mmap() 是 POSIX.4 的標準函數, 用途是將 device 給 map 到記憶體, 也就是底下粗體字的地方:
int v4l_mmap_init(v4l_device *vd)
{
if (v4l_get_mbuf(vd) < 0)
return -1;
if ((vd->map = mmap(0, vd->mbuf.size, PROT_READ|PROT_WRITE, MAP_SHARED, vd->fd, 0)) < 0) {
perror("v4l_mmap_init:mmap");
return -1;
}
return 0;
}
PROT_READ 表示可讀取該 memory page , PROT_WRITE 則是可寫入, MAP_SHARED 則是讓這塊 mapping 的區域和其它 process 分享。第一個參數旦 0 是啟始位置, vd->mbuf.size 則是長度 (length)。vd->fd 則是 device 的 file description, 最後一個參數是 offset。
v4l_get_mbuf() 和之前介紹過的沒有什麼出入。在新的 device_init() 函數裡, 我們也把初始化好的 mmap 相關資訊印出。
channel 與 norm
我們提過, 在做 v4l_mmap_init() 前要先做 channel 與 norm 的設定, 分別是 v4l_get_channels() 與 v4l_set_norm() 函數。
在這裡要捕充說明一點, 以筆者的 CCD 頭來講, 和擷取卡是以 Composite1 連接, 所以在 channel 方面, 就要利用 v4l_switch_channel() 將 channel 切到 Composite1 端。
v4l_switch_channel() 程式碼如下:
int v4l_switch_channel(v4l_device *vd, int c)
{
if (ioctl(vd->fd, VIDIOCSCHAN, &(vd->channel[c])) < 0) {
perror("v4l_switch_channel:");
return -1;
}
return 0;
}
傳入的 c 是 channel, 而 channel number 我們已經在 device_init() 裡列印出來:
Channel 0: Television
Channel 1: Composite1
Channel 2: S-Video
我們可以看到 Composite1 位於 Channel 1 (由 0 算起), 所以 v4l_switch_channel() 的參數 c 要傳入 1。
如何設定 norm
norm 的話就比較單純一點, 參數如下:
VIDEO_MODE_PAL
VIDEO_MODE_NTSC
VIDEO_MODE_SECAM
VIDEO_MODE_AUTO
這些參數都定義於 videodev.h 檔案裡。v4l_set_norm() 是我們用來設定 norm 的函數, 程式碼如下:
int v4l_set_norm(v4l_device *vd, int norm)
{
int i;
for (i = 0; i < vd->capability.channels; i++) {
vd->channel[i].norm = norm;
}
if (v4l_get_capability(vd)) {
perror("v4l_set_norm");
return -1;
}
if (v4l_get_picture(vd)) {
perror("v4l_set_norm");
}
return 0;
}
要仔細注意, 我們是對所有的 channel 設定 norm, 設定完成後, 底下又做了一次 v4l_get_capability(), 主要目的是確保每個 channel 的設定都有被設定成功。然後呼叫 v4l_get_picture。
v4l_get_capability() 會利用 ioctl() 取得設備檔的相關資訊,並且將取得的資訊放到 struct video_capability 結構裡。同理,v4l_get_picture() 也會呼叫 ioctl() ,並將影像視窗資訊放到 struct video_picture 結構。
如何 get picture
取得設備資訊後,我們還要再取得影像資訊,所謂的影像資訊指的是輸入到影像捕捉卡的影像格式。
在 _v4l_struct 結構裡,我們宣告 channel 如下:
struct video_picture picture;
初始化 picture 的意思就是要取得輸入到影像捕捉卡的影像資訊,我們設計 v4l_get_ picture() 函數來完成這件工作。
v4l_get_ picture () 完整程式碼如下:
int v4l_get_picture(v4l_device *vd)
{
if (ioctl(vd->fd, VIDIOCGPICT, &(vd->picture)) < 0) {
perror("v4l_get_picture:");
return -1;
}
return 0;
}
傳遞VIDIOCGPICT 給 ioctl() 則會傳回影像的屬性 (image properties),這裡則是將影像屬性存放於 vd-> picture。
這部份我們也曾經介紹過, 在這裡要再捕充一點。如果是以 GREY 方式擷取影像, 那麼我們可以利用 VIDIOCSPIC 來設定像素的亮度與灰階度, 請參考 API.html 裡的 struct video_picture 說明。
初始化 grab
初始化 grab 的程式碼如下:
if (v4l_get_picture(&vd)) return -1;
if (v4l_set_palette(&vd, DEFAULT_PALETTE)) return -1;
if (v4l_grab_init(&vd, screen_width, screen_height)) return -1;
if (v4l_grab_sync(&vd)) return -1;
v4l_get_picture() 與之前介紹的一樣, 而 v4l_set_palette() 則是用來設定調色盤, 由於我們希望得到的是 RGB32, 所以 DEFAULT_PALETTE 定義成:
#define DEFAULT_PALETTE VIDEO_PALETTE_RGB32
如果沒有硬體轉換, 前一篇文章 (4) 我們也提到將 YUV (PAL) 轉成 RGB 的方法了。再來將就是對 grab 做初始化, v4l_grab_init()
int v4l_grab_init(v4l_device *vd, int width, int height)
{
vd->mmap.width = width;
vd->mmap.height = height;
vd->mmap.format = vd->picture.palette;
vd->frame_current = 0;
vd->frame_using[0] = FALSE;
vd->frame_using[1] = FALSE;
return v4l_grab_frame(vd, 0);
}
初始化的目的是將 mmap 結構填入適當的值。針對 RGB32、NTSC 的 CCD 影像擷取, mmap 的大小不妨設定成 640*480 或 320*240 都可以, 給定 mmap 的大小後, 再來還要將 format 填入調色盤類型。
最後設定 frame_current 變數與 frame_using[] 陣列, 這裡等於上一篇 (4) 介紹的 frame 變數與 framestat[] 陣列。
如何所有的程式碼都沒有錯誤, 當裝置正常軀動時, 就可以看到底下的初始化訊息, 這裡的訊息比起之前的範例更清楚、完整:
/dev/video0: initialization OK... BT878(Chronos Video Shuttle I)
3 channels
3 audios
Channel 0: Television (NTSC)
Channel 1: Composite1 (NTSC)
Channel 2: S-Video (NTSC)
v4l: mmap's address = 0x40173000
v4l: mmap's buffer size = 0x410000
v4l: mmap's frames = 2 (32 max)
v4l: frames 0's offset = 0x0
v4l: frames 1's offset = 0x208000
v4l: channel switch to 1 (Composite1)
Image pointer: 0x4037b000
v4l_grab_frame() 的用處
讀者可能還不明白 v4l_grab_frame() 的用途, v4l_grab_frame() 是真正將影像放到 mmap 裡的函數。
我們重寫一次 v4l_grab_frame() 函數, 並且再說明一次:
int v4l_grab_frame(v4l_device *vd, int frame)
{
if (vd->frame_using[frame]) {
fprintf(stderr, "v4l_grab_frame: frame %d is already used.\n", frame);
return -1;
}
vd->mmap.frame = frame;
if (ioctl(vd->fd, VIDIOCMCAPTURE, &(vd->mmap)) < 0) {
perror("v4l_grab_frame");
return -1;
}
vd->frame_using[frame] = TRUE;
vd->frame_current = frame;
return 0;
}
因為我們用 frame_using[] 陣列來紀錄那個 frame 已經被使用, 所以一開始當然要先判斷目前的 frame 是否已經被使用:
if (vd->frame_using[frame]) {
fprintf(stderr, "v4l_grab_frame: frame %d is already used.\n", frame);
return -1;
}
如果沒有被使用, 就把 mmap 的 frame 填入 frame 編號, 然後利用 VIDIOCMCAPTURE 擷取出影像。結束前要把目前 frame 的狀態標示成使用中 (frame_using[]), 然後把 frame_current 指定成現在的 frame, 完成工作後離開。
mmap 如何做 filp-flop
這是一位讀者問的問題。這個問題問的相當聰明, 每個人可能都有不同的方法來做 flip-flop 的動作, 這裡筆者以 2 個 frame 為例, 我們可以再寫一個函數來做 flip-flop:
int device_grab_frame()
{
vd.frame_current = 0;
if (v4l_grab_frame(&vd, 0) < 0)
return -1;
return 0;
}
int device_next_frame()
{
vd.frame_current ^= 1;
if (v4l_grab_frame(&vd, vd.frame_current) < 0)
return -1;
return 0;
}
device_next_frame() 是主要核心所在, 因為我們只有二個 frame, 所以 frame_current 不是 0 就是 1。
擷取出來的影像放在那裡
因為我們特別寫了上面的函數來做 mmap 的 flip-flop, 所以在主程式裡就改用 device_next_frame 來持續擷取影像。
所以配合主程式, 我們的程式寫法如下:
device_next_frame(); //Ok, grab a frame.
device_grab_sync(); //Wait until captured.
img = device_get_address(); //Get image pointer.
printf("\nImage pointer: %p\n", img);
這段程式就是我們的重點好戲, 當我們呼叫 device_next_frame() 擷取 frame 之後, 必須做一個等待的動作, 讓 frame 擷取完成再取出影像。
v4l_grab_sync() 程式碼如下:
int v4l_grab_sync(v4l_device *vd)
{
if (ioctl(vd->fd, VIDIOCSYNC, &(vd->frame_current)) < 0) {
perror("v4l_grab_sync");
}
vd->frame_using[vd->frame_current] = FALSE;
return 0;
}
利用 VIDIOCSSYNC 等待完成後, 別忘了將目前 frame 的狀態改回未被使用。接下來我們要問, 擷出出來的 frame 到底放到那裡去了呢?
答案就是之們利用 mmap() 將 device 所 map 的記憶體裡, 因為我們是利用 mmap (flip-flop) 方式, 所以會有 2 個 (或以上) 的 frame, 這時就要計算一下 offset, 才知道到底目前的影像資料被放到那裡了。
算式如下:
vd.map + vd.mbuf.offsets[vd.frame_current]
device_get_address() 函數就是這麼回事。
如何輸出影像資料呢
輸出影像資料的方法很多, 可以直接輸出到 framebuffer 上, 或是利用 SDL 顯示。在這裡筆者要示範最原始的方法 – 輸出到檔案裡。
當我們利用 device_get_address() 取得 frame 的影像資料後, 再將 frame 的影像資料輸出成 PPM 格式的檔案。
程式碼如下:
FILE *fp;
fp = fopen("test.ppm", "w");
fprintf(fp, "P6\n%d %d\n255\n", NTSC_WIDTH, NTSC_HEIGHT);
fwrite(img, NTSC_WIDTH, 3*NTSC_HEIGHT, fp);
fclose(fp);
先利用 fprintf() 寫入 PPM 檔案的檔頭資訊, 然後以 fwrite() 將傳回的影像資料寫到檔案裡。
img 指向記憶體裡的 frame 影像資料, 寫入時, 請特別注意粗體字的地方, 因為我們是用 RGB32 的調色盤, 而 RGB 是以 3 個 sample 來表示一個 pixel, 所以要乘上 3。
如果是 GREY 調色盤, 就不用再乘 3 了。最後將輸出的 PPM 檔案轉換格式成 TIFF 就可以用一盤的繪圖軟體打開了:
linux$ ppm2tiff test.ppm test.tiff
將影像存成 JPEG 的方法
最後我們再完成一個功能, 就可以實作出一個完整的 Webcam 軟體。之前我們將影像存成 PPM 格式的圖檔, 不過因為檔案過太, 會造成傳輸的不便。因此, 我們勢必要將影像資料存成更小的檔案才具實用性。
JPEG 或MJPEG 都是在本文第 1 篇介紹過的格式。以 JPEG 來存放圖檔, 相當容易可以實作出 Webcam 的功能, 但缺點就是無法傳送聲音資料。
我們使用 mpeglib 來完成這項任務, mpeglib 可至 www.ijg.org 下載。
將影像資料存成 JPEG 的方法在「各大」與 video streaming 有關的軟體 (例如: xawtv) 都可以看得到範例。不過因此這部份已脫離 v4l 的主, 所以筆者只列出底下的 write_jpeg() 完整函數, 供讀者使用:
int write_jpeg(char *filename, unsigned char * img, int width, int height, int quality, int gray)
{
struct jpeg_compress_struct jcfg;
struct jpeg_error_mgr jerr;
FILE *fp;
unsigned char *line;
int line_length;
int i;
if ((fp = fopen(filename,"w")) == NULL) {
fprintf(stderr,"write_jpeg: can't open %s: %s\n", filename, strerror(errno));
return -1;
}
jcfg.image_width = width;
jcfg.image_height = height;
jcfg.input_components = gray ? 1: 3; // 3 sample per pixel (RGB)
jcfg.in_color_space = gray ? JCS_GRAYSCALE: JCS_RGB;
jcfg.err = jpeg_std_error(&jerr);
jpeg_create_compress(&jcfg);
jpeg_stdio_dest(&jcfg, fp);
jpeg_set_defaults(&jcfg);
jpeg_set_quality(&jcfg, quality, TRUE);
jpeg_start_compress(&jcfg, TRUE);
line_length = gray ? width : width * 3;
for (i = 0, line = img; i < height; i++, line += line_length)
jpeg_write_scanlines(&jcfg, &line, 1);
jpeg_finish_compress(&jcfg);
jpeg_destroy_compress(&jcfg);
fclose(fp);
return 0;
}
利用 mpeglib 寫入 JPEG 影像資料時, 必須分別對每行 scanline 寫入。呼叫範例:
write_jpeg("test01.jpg", img, NTSC_WIDTH, NTSC_HEIGHT, 50, FALSE );
第一個參數是圖檔名稱, 第二個參數是影像資料, 然後第三、第四個參數接著影像的大小, 第五個參數 50 表示 JPEG 圖檔的壓縮品質 (quality), 最後一個參數 FALSE 表示影像資料不是 grey (灰階) 影像。
灰階影像與彩色影像的差別在於 input_components、in_color_space 與 scanline 的長度。
結語
在一連串的 Video Streaming 主題裡, 我們學到 video4linux 擷取影像的方式, 以 mmap (flip-flop) 來連續擷取影像, 並做到 VOD 的功能是我們的最終目的。到這裡為止, 我們已經有能力實作出簡單的 Webcam 軟體, 類似這種取固定間隔傳送影像的方式應用也很廣, 例如路口交通狀況回報。
利用到這裡所學的方法, 將擷取的影像存成 JPEG, 然後放到 Web 上, 固定一段時間更新, 我們也可以設計一套簡單的路口交通狀況回報系統, 或是家裡的監視系統。
後面接著的主題, 將會以現有的程式為基礎, 實作真正具有 VOD 能力的軟體。