2019年9月21日土曜日

キャプチャソフト開発 その6 画面キャプチャ

画面キャプチャについて研究した際の覚書。

TL;DR

画面を保存する以下の関数[1]を使ってみた。


PaintDesktopとPrintWindowはWindows 7とWindows 8.1で動作に違いがあった。
BitBltはともに同じ結果が得られたため、スクリーンキャプチャに採用する関数にはBitBltが適していると考えられる。

事前知識

かなり強引に要約した。

Q. 画面って何?
A. スクリーン、すなわちウィンドウを含むデスクトップ

「デスクトップ」と「スクリーン」を区別しよう[2]。
「デスクトップ」は壁紙やアイコンから成る画面であり、「スクリーン」はそれらに表示されているすべてのウィンドウを加えたもの。
今回作ろうとしているキャプチャソフトは、ウィンドウを含む画面全体なので、「スクリーン」を対象とすることになる。

Q. 「デスクトップ」と「シェル」の違いは?
A. 「デスクトップ」はトップレベルウインドウの親、「シェル」はUIを提供する

「デスクトップ」ウィンドウは、すべてのトップレベルウインドウの親である[3]。
一方で、「シェル」はログオン時にUIを提供する機能を持つものである[4]。
デフォルトのシェルには「explorer.exe」が指定されている。

Q. OSのバージョンによりシェルの機能が異なる?
A. Windows 7と8.1以降ではシェルの機能が変わっている

Windows 7では、壁紙の描画をカーネルが行い、デスクトップ上のアイコンやそのキャプション、タスクバーといったものをexplorer.exeが行っていた。
しかし、Windows 8以降では壁紙の描画はexplorer.exeが行うようになった[4]。
このことから、画面を保存する3つの関数の動作が、Windows 7とWindows 8以降で異なる可能性がある。

Q. ビットマップの種類が複数?
A. DIBとDDBという異なる形式がある

ビットマップにはDIB(Device Independent Bitmap)とDDB(Device Dependent Bitmap)の二種類が存在する[5]。

DDBの特徴
  • 特定のデバイスに依存する
  • 原点が左上(トップダウン)
  • 画素にアクセスするためには、ゲッタ・セッタ関数を用いる必要がある(安全だが遅い)
  • 画像データを長方形として一まとめで扱うため、描画の計算が軽い
DIBの特徴
  • 特定のデバイスに依存しない
  • 原点が左下(ボトムアップ)
  • 画素にアクセスするためには、メモリに直接アクセスするだけでよい(必ずしも安全ではないが速い)
  • 描画の計算がDDBに比べ遅い

実施内容

今回はWindows 7, Windows 8.1のOSをそれぞれ搭載した二台のPCを用いた。
画面を保存する以下の関数の動作を比較する。

開発・実行環境

Windows 7

プロセッサ: Intel(R) Core(TM) i5 CPU @2.67GHz
メモリ: 4.00 GB
OS: Microsoft Windows 7 Home Premium (64bit)
compiler: Microsoft(R) C/C++ Optimizing Compiler Version 19.11.25507.1 for x86

Windows 8.1

プロセッサ: Intel(R) Core(TM) i5-5200U CPU @2.20GHz
メモリ: 8.00 GB
OS: Windows 8.1 (64bit)
compiler: Microsoft (R) C/C++ Optimizing Compiler Version 19.00.24215.1 for x86

共通

ライブラリ: libpng 1.6.37

コード説明

テスト関数

  1. bool CaptureTest(TESTCASE test_case, PNGData *png_data) {
  2. assert(png_data);
  3.  
  4. // The size of desktop window is acquired.
  5. RECT rect;
  6. GetWindowRect(GetDesktopWindow(), &rect);
  7. const int width = rect.right;
  8. const int height = rect.bottom;
  9.  
  10. // Memory device context is created.
  11. HDC memDC = CreateCompatibleDC(NULL);
  12.  
  13. // DIB section and DDB is created.
  14. BITMAPINFOHEADER bmiHeader;
  15. ZeroMemory(&bmiHeader, sizeof(bmiHeader));
  16. bmiHeader.biSize = sizeof(bmiHeader);
  17. bmiHeader.biWidth = width;
  18. bmiHeader.biHeight = height;
  19. bmiHeader.biPlanes = 1;
  20. bmiHeader.biBitCount = 24; // 24 bit BITMAP.
  21.  
  22. BITMAPINFO bmi;
  23. bmi.bmiHeader = bmiHeader;
  24.  
  25. LPVOID memDIB = NULL;
  26. HBITMAP memBM = CreateDIBSection(NULL, (LPBITMAPINFO)&bmi, DIB_RGB_COLORS,
  27. &memDIB, NULL, 0);
  28.  
  29. // Set DDB to memory device context.
  30. HBITMAP prevBM = (HBITMAP)SelectObject(memDC, memBM);
  31.  
  32. // DIB Section is set to memory DC.
  33. switch (test_case) {
  34. case TESTCASE_PAINT_DESKTOP:
  35. if (PaintDesktop(memDC) == 0) {
  36. SelectObject(memDC, prevBM);
  37. DeleteObject(memBM);
  38. DeleteDC(memDC);
  39. return false;
  40. }
  41. break;
  42. case TESTCASE_PAINT_WINDOW:
  43. if (PrintWindow(GetShellWindow(), memDC, 0) == 0) {
  44. SelectObject(memDC, prevBM);
  45. DeleteObject(memBM);
  46. DeleteDC(memDC);
  47. return false;
  48. }
  49. break;
  50. case TESTCASE_BITBLT:
  51. if (BitBlt(memDC, 0, 0, width, height, GetWindowDC(GetDesktopWindow()), 0,
  52. 0, SRCCOPY) == 0) {
  53. SelectObject(memDC, prevBM);
  54. DeleteObject(memBM);
  55. DeleteDC(memDC);
  56. return false;
  57. }
  58. break;
  59. default:
  60. break;
  61. }
  62.  
  63.  
  64. // DIB is converted to PNG.
  65. png_data->width = width;
  66. png_data->height = height;
  67. png_data->bit_depth = 8;
  68. png_data->rowbytes = width * 4;
  69. png_data->channels = 4;
  70. png_data->color_type = PNG_COLOR_TYPE_RGBA;
  71. png_data->interlace_type = PNG_INTERLACE_NONE;
  72. png_data->compression_type = PNG_COMPRESSION_TYPE_DEFAULT;
  73. png_data->filter_type = PNG_FILTER_TYPE_DEFAULT;
  74.  
  75. png_data->red_buffer.resize(width * height);
  76. png_data->green_buffer.resize(width * height);
  77. png_data->blue_buffer.resize(width * height);
  78. png_data->alpha_buffer.resize(width * height);
  79.  
  80. int bm_width = width * 3;
  81. if ((width * 3) % 4) {
  82. bm_width += (4 - (width * 3) % 4); // Set padding.
  83. }
  84.  
  85. for (int y = 0; y < height; y++) {
  86. for (int x = 0; x < width; x++) {
  87. png_data->blue_buffer[y * width + x] =
  88. *((LPBYTE)memDIB + (height - 1 - y) * bm_width + x * 3);
  89. png_data->green_buffer[y * width + x] =
  90. *((LPBYTE)memDIB + (height - 1 - y) * bm_width + x * 3 + 1);
  91. png_data->red_buffer[y * width + x] =
  92. *((LPBYTE)memDIB + (height - 1 - y) * bm_width + x * 3 + 2);
  93. png_data->alpha_buffer[y * width + x] = 255;
  94. }
  95. }

三つの関数の比較を一つの関数にまとめた。
プログラムの流れを説明する。

  1. スクリーンの大きさの取得
    GetWindowRectを用いた。
     
  2. メモリデバイスコンテキストの取得
    CreateCompatibleDCを用いた。
     
  3. ビットマップセクションの取得
    メモリデバイスコンテキストにデフォルトで1x1のモノクロビットマップが選択されている[6]。
    これを自前で作成したビットマップで置き換える。
    そこで、CreateDIBSectionという関数を用いる。
    この関数を使うことでDDBと、それに対応したDIBを同時に作成できる。

    なお、CreateCompatibleBitmapという関数があるが、この関数はDDBを返す。
    DDBは直接ピクセルのメモリ領域にアクセスすることができず都合が悪い。
     
  4. 画面のキャプチャ
    1. PaintDesktopを使う場合
      1. PaintDesktop(memDC)
      指定されたデバイスコンテキストの"clipping region"(ビットマップのこと?)にデスクトップのパターンもしくは壁紙を設定する。
       
    2. PrintWindowを使う場合
      1. PrintWindow(GetShellWindow(), memDC, 0)
      指定されたデバイスコンテキストに指定されたウィンドウの画面をコピーする。
      この関数は、スクリーンキャプチャに限らず、任意のアプリケーションやコントロールのウインドウで使用できると思われる。
       
    3. BitBltを使う場合
      1. BitBlt(memDC, 0, 0, width, height, GetWindowDC(GetDesktopWindow()), 0, 0, SRCCOPY)
      あるデバイスコンテキストから別のデバイスコンテキストへ、矩形領域の画素をビットブロック転送する。
       
  5. ビットマップからPNGへの変換
    PNGDataは先日の投稿[7]で作成した構造体である。
    特に注意が必要なのは、画素をDIBからPNGに変換する部分。
    1. int bm_width = width * 3;
    2. if ((width * 3) % 4) {
    3. bm_width += (4 - (width * 3) % 4); // Set padding.
    4. }
    5.  
    6. for (int y = 0; y < height; y++) {
    7. for (int x = 0; x < width; x++) {
    8. png_data->blue_buffer[y * width + x] =
    9. *((LPBYTE)memDIB + (height - 1 - y) * bm_width + x * 3);
    10. png_data->green_buffer[y * width + x] =
    11. *((LPBYTE)memDIB + (height - 1 - y) * bm_width + x * 3 + 1);
    12. png_data->red_buffer[y * width + x] =
    13. *((LPBYTE)memDIB + (height - 1 - y) * bm_width + x * 3 + 2);
    14. png_data->alpha_buffer[y * width + x] = 255;
    15. }
    16. }
    17.  

ピクセルの変換の例:サイズ2x2の画像
DIBの一列の長さが4の倍数バイトとなるようパッディングを入れる。
DIBはボトムアップ配列、PNGはトップダウン配列で画素情報を格納する。
画像のプロパティにより、この図の限りではないので注意。

メイン関数

  1. #include <stdio.h>
  2. #include <wchar.h>
  3. #include <windows.h>
  4. #include <windowsx.h>
  5. #include "./resource.h"
  6. #include "./util.h"
  7.  
  8. namespace {
  9. constexpr wchar_t WINDOW_NAME[] = L"Template window";
  10. constexpr wchar_t CLASS_NAME[] = L"Template class";
  11. } // namespace
  12.  
  13. int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
  14. LPTSTR lpsCmdLine, int nCmdShow) {
  15. (void)hInstance;
  16. (void)hPrevInstance;
  17. (void)lpsCmdLine;
  18. (void)nCmdShow;
  19.  
  20. #ifdef DEBUG
  21. FILE* fp = nullptr;
  22. AllocConsole();
  23. _wfreopen_s(&fp, L"CONOUT$", L"w", stdout);
  24. _wfreopen_s(&fp, L"CONOUT$", L"w", stderr);
  25. _wfreopen_s(&fp, L"CONIN$", L"r", stdin);
  26. #endif
  27.  
  28. #ifdef DEBUG
  29. fwprintf(stdout, L"Hello world to stdout!\n");
  30. fwprintf(stderr, L"Hello world to stderr!\n");
  31. fwprintf(stderr, L"\n");
  32. #endif
  33.  
  34. PNGData png_data;
  35.  
  36. // Test case 1 : Use of PaintDesktop().
  37. if (!CaptureTest(TESTCASE_PAINT_DESKTOP, &png_data)) {
  38. MessageBox(NULL, L"Case 1 failed.", L"Error", MB_OK);
  39. } else {
  40. WritePNGFile(L"case_1.png", png_data);
  41. }
  42.  
  43. // Test case 2 : Use of PaintWindow().
  44. if (!CaptureTest(TESTCASE_PAINT_WINDOW, &png_data)) {
  45. MessageBox(NULL, L"Case 2 failed.", L"Error", MB_OK);
  46. } else {
  47. WritePNGFile(L"case_2.png", png_data);
  48. }
  49.  
  50. // Test case 3 : Use of BitBlt().
  51. if (!CaptureTest(TESTCASE_BITBLT, &png_data)) {
  52. MessageBox(NULL, L"Case 3 failed.", L"Error", MB_OK);
  53. } else {
  54. WritePNGFile(L"case_3.png", png_data);
  55. }
  56.  
  57. // Everything is done!
  58. MessageBox(NULL, L"End of test.", L"main.exe", MB_OK);
  59. DestroyWindow(NULL);
  60.  
  61. #ifdef DEBUG
  62. FreeConsole();
  63. #endif
  64. return 0;
  65. }
呼び出しはこんな感じで行う。

注意点

実行の際には、Windows 8.1では、main.exeを右クリックし、プロパティを選択、互換性のタブを開き、「高DPI設定では画面のスケーリングを無効にする」にチェックを入れた。

結果と考察

以下の結果が得られた。

  • PaintDesktopを用いた場合の画像
    • Windows 7: 関数が失敗した
    • Windows 8.1: 壁紙のみ得られた
Windows 7で失敗した原因は不明。
Windows 8で壁紙のみが取得できたのは、リファレンスにある通りの動作。
環境を変えると動かなくなる。嫌なパターンだ。

  • PrintWindowを用いた場合の画像
    • Windows 7: 壁紙とアイコンが得られた
    • Windows 8.1: 壁紙のみ得られた
文献[4]には、Windows 7では壁紙の描画がカーネルで行われているとある。
また、デスクトップのアイコンとキャプションのみが映ったという例もある[8]。
そのため、Windows 7はカーネルが担当する壁紙は映らずに、シェルが担当するであろうアイコンのみが映り、Windows 8ではシェルが担当する壁紙とアイコンの両方が映るのだろうと予測していた。

しかし、実際は違った。
Windows 7でアイコンが映りこみ、Windows 8.1壁紙が映りこむことは予想された。
しかし、Windows 7で壁紙が映りこみ、Windows 8.1ではアイコンが映らないのは予想されなかった。
この原因の追究は一旦、保留としておく。

  • BitBltを用いた場合の画像
    • Windows 7 & Windows 8.1:壁紙とアイコンに加え、すべてのウィンドウが映った 
綺麗に撮れた。
めでたしめでたし。


PaintDesktopとPrintWindowがうまくいかなかった原因は不明である。
まず考えられるのは、「お前の環境が悪い」ケースである。
PaintDesktopのようなわかりやすい関数がWindows 7で失敗しているのは怪しい。

一方で、OSやAPIが壊れている可能性は微粒子レベルかもしれないが、無いとは言い切れない。
特に、今回使用したWindows 7搭載のPCはかなり古いのだ。

まとめ


  • PaintDesktopはWindows 7で失敗した
  • PrintWindowはWindows 7とWindows 8.1で動作に違いがあった
  • BitBltはWindows 7とWindows 8.1でスクリーンのキャプチャに成功した

よって、BitBltが一番安心できる。

感想

ある環境では動いて、ある環境では動かないという、後味が悪い結果となってしまった。
とはいえ、BitBltがしっかり動くのだということが分かったのは大きな収穫だ。
これでキャプチャソフトの完成に一歩近づいた。

GitHub

今回使用したコードはここに上げておく(タグ:"v_scr_captured")。

参考文献

  1. ビットマップ / スクリーンキャプチャ
  2. スクリーンキャプチャ
  3. デスクトップウィンドウとシェルウィンドウ
  4. Windows 10 でのシェルの置き換えについて
  5. Windows bitmap
  6. DDBを作る
  7. キャプチャソフト開発 その5 libpngを使ってみる
  8. デスクトップのスナップショット


0 件のコメント:

コメントを投稿

コメント表示は承認制に設定しています