Linux 的應用(刊載於 PC2000 雜誌十一月號)-- Video Streaming 探討 (5)

jollen 發表於 September 14, 2001 8:31 AM

本期將以完整的程式範例為主, 說明之前未深入說明的地方。並且更詳細地介紹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 能力的軟體。

Jollen's Blog 使用 Github issues 與讀者交流討論。請點擊上方的文章專屬 issue,或 open a new issue

您可透過電子郵件 jollen@jollen.org,或是 Linkedin 與我連絡。更歡迎使用微信,請搜尋 WeChat ID:jollentw