【C# Form】モニタの拡大率とスナップショット

# モニタの拡大率とスナップショット

ええと、本記事は、
以前に私が書いた[マルチモニタ環境での座標の扱い](https://qiita.com/Tabito/items/f2fc04b134d4bbcf6261)の続きにあたるものです。
実はあのとき、１つ未解決事項だったものがあったんですが、解決したので記事にします。

ディスプレイの拡大率です。

![disp03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1443439/a64110d4-1944-fdf4-0d77-386a02a6819d.png)
こいつ。

これを取る方法が見つからず、しばらく探し回ったんですけど、
結論からいえば、WindowsAPIの``EnumDisplaySettingsA``を利用すれば取れました。
それが知りたいぜって方は下の「モニタの拡大率/縮小率の取得」まで飛ばしてください。

そこまで、順を追って説明していきます。

#### 環境

C# - Winodws Form Application (Visual Studio 2022 , .NET Framework 4.8)

## システム上の表示領域について

今現在の私の環境がこちらです。

![disp04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1443439/721fa00e-1dd9-ee83-c308-35cfb74a16c3.png)
真ん中の３番がプライマリディスプレイ、その左側の１番が前からのサブモニタです。
その状況から右側に１台増えました。この２番のサブモニタが新しめのポータブルディスプレイであり、実際の大きさに比べて素の解像度が高めで、文字が小さいため、拡大を施し、上の設定画面の通り、解像度1920×1280、拡大率を **200%** にしています。このモニタだけを拡大しているのがポイントです。

で、これの座標がどうなっているのか。調べてみました。

この状況では、表示領域はこうなってます。
![disp05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1443439/37d4a174-25a7-058b-2afd-615cf6dee0e5.png)
プライマリディスプレイの３番モニタの左上隅が(0,0)になります。
そして左のモニタはその左側にあるのでX座標はマイナスになります。
つまり、フォームを配置するのであれば適切な座標に置けば、該当するモニタに表示してくれる‥というのが前回のお話でした。
※これについての深堀は、[前回](https://qiita.com/Tabito/items/f2fc04b134d4bbcf6261)の記事をどうぞ。

本題は拡大率200%である２番モニタです。

上の設定画面のモニタ配置図と異なっているのがわかります。
右端の座標は2880、1920を引いて960。下端の座標は944、304を引いて640。
２番モニタの領域は、2倍に拡大しているため、システム上では半分の960×640として扱われているわけです。

このことから、
フォームは、どこのモニタに移動させてもWidthの値は変動しません。
表示領域のほうが半分に狭くなっているので、Widthはそのままに見た目は2倍の大きさになっているようOS側(ないしモニタ側)が表示してくれます。ということで、フォームの作り手は通常、これを意識する必要はありません。ということが分かりました。

通常はですね。
ここに特殊な例があります。その一つがスナップショットを取るときの話です。

<details><summary>余談：マウスの移動について</summary>

余談ですが、マウスの移動について調べてみると、こちらは「設定画面の図」の通りに移動するようです。つまりこの例だと３番モニタの下端は２番モニタの中央付近と繋がっているので、３番モニタの下端から右に移動させると、２番モニタの中央に出てきます。マウスのY座標は1079から650などに変化します。

不思議だなぁ‥
</details>

## スナップショットを取る

.NETで画面のキャプチャ（スクリーンショット）を取りたい。
この処理はわりとポピュラーで、その辺で調べてみればこういう感じのが見つかります。
```CSharp
//using System.Drawing;
//using System.Drawing.Imaging;
//using System.Windows.Forms;
private void button1_Click(object sender, EventArgs e)
{
    // 現在のマウスカーソルがあるスクリーンを取得
    Screen currentScreen = Screen.FromPoint(Cursor.Position);    
    // スクリーンのサイズを取得
    Rectangle screenBounds = currentScreen.Bounds;
    // Bitmapオブジェクトをスクリーンのサイズで作成
    using (Bitmap bitmap = new Bitmap(screenBounds.Width, screenBounds.Height))
    {
        // グラフィックスオブジェクトを作成
        using (Graphics g = Graphics.FromImage(bitmap))
        {
            // 画面全体をキャプチャ
            g.CopyFromScreen(screenBounds.Location, Point.Empty, screenBounds.Size);
        }
        // PNG形式で画像を保存
        bitmap.Save("ss.png", ImageFormat.Png);
    }
}
```

対象のモニタ、Screenは``Screen.FromPoint(座標)``（ないし``Screen.FromHandle(フォーム.Handle)``）で取得し、その座標を空の画像データのグラフィックオブジェクトの``CopyFromScreen``メソッドの引数に指定します。引数はそれぞれ‥「転送元左上座標」「転送先左上座標(通常0,0)」「転送元サイズ」です。

※上記説明の通り、座標はモニタ同士で被らないため、座標だけで「どのモニタのモノ」かを指定する必要はありません。

わりとシンプルに書け、そして動作するのですが、
実際にこのプログラムを使って使用すると、拡大率が100％ではないモニタではおかしい形になります。試しに私の環境の２番モニタで撮ってみると、こうなります。

![disp06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1443439/b185ffc8-f90a-91f5-074e-a20d2f5091ad.png)

右上の1/4になります。左上のX,Y座標は正しいようですから、第一引数の「転送元左上座標」は拡大率の補正が考慮されています。一方で、第三引数の「転送元サイズ」は、この補正が考慮されていないようなのです。

つまり、こうです。
プログラム側が「起点から960pxの幅のデータを転送してくれ～」とOS/モニタに伝えます。
そうすると、OS/モニタ側は「私が今表示している画面は1920px。つまり、起点から960pxを転送すれば良い」と解釈します。よって、得られる画像は半分の大きさになるということです。

‥なんか``CopyFromScreen``の不具合のような気もしてきましたが、
ともかく、これを回避するには、転送元のサイズを2倍にすればよいです。

```CSharp
//#前略
        // 転送元サイズを2倍に
        Size srcSize = new Size(screenBounds.Width * 2, screenBounds.Height * 2);
        // Bitmapオブジェクトを転送元サイズと同じサイズで作成
        using (Bitmap bitmap = new Bitmap(srcSize.Width, srcSize.Height))
        {
            using (Graphics g = Graphics.FromImage(bitmap))
            {
                // 画面全体をキャプチャ
                g.CopyFromScreen(screenBounds.Location, Point.Empty, srcSize);
            }
            bitmap.Save("ss.png", ImageFormat.Png);
        }
```
もちろん、得られる画像は幅1920pxです。960pxで保存すると画質が下がります。
モニタ側では1920の領域に2倍の大きさのフォームを表示しているので、これが本来の解像度のデータということですね。実際200%にしてもドットが2倍粗くなったりもしないので、なるほどそうかという感じです。

で、このマジックナンバーの2をどうにかして取得する方法はないのか？という話になり、
冒頭の話に戻ってくるわけです。

## モニタの拡大率/縮小率の取得

結論から言います。
WindowsAPIの``EnumDisplaySettingsA``を利用すれば、200%という拡大率そのものは取れないものの、モニタが持つ元の解像度「1920×1280」が取得できます。システム上で扱われている解像度は、通常通りScreenオブジェクトから取得できますので、この２つを比較すれば良いです。

以下では百分率の整数にしていますが、もちろん、これは単なる好みです。
```CSharp
public static int getScaleOfScreen(Screen screen)
{
    DEVMODE dm = new DEVMODE();
    dm.dmSize = (short)Marshal.SizeOf(typeof(DEVMODE));
    int ENUM_CURRENT_SETTINGS = -1;
    EnumDisplaySettingsA(screen.DeviceName, ENUM_CURRENT_SETTINGS, ref dm);
    Console.Out.WriteLine($"モニタサイズ:{dm.dmPelsWidth}x{dm.dmPelsHeight}");
    Console.Out.WriteLine($"画面解像度: {screen.Bounds.Width}x{screen.Bounds.Height}");
    int scale = (dm.dmPelsWidth * 100) / screen.Bounds.Width;
    Console.Out.WriteLine($"拡大率: {scale}%");
    return scale;
}
```
APIと構造体は宣言しないと使えません。こう。
```CSharp
[StructLayout(LayoutKind.Sequential)]
public struct DEVMODE
{
    private const int CCHDEVICENAME = 0x20;
    private const int CCHFORMNAME = 0x20;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 0x20)]
    public string dmDeviceName; // デバイス名
    public short dmSpecVersion;
    public short dmDriverVersion;
    public short dmSize; // DEVMODE構造体のサイズ
    public short dmDriverExtra;
    public int dmFields;
    public int dmPositionX; // ディスプレイ位置X
    public int dmPositionY; // ディスプレイ位置Y
    public ScreenOrientation dmDisplayOrientation;
    public int dmDisplayFixedOutput;
    public short dmColor;
    public short dmDuplex;
    public short dmYResolution;
    public short dmTTOption;
    public short dmCollate;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 0x20)]
    public string dmFormName;
    public short dmLogPixels;
    public int dmBitsPerPel;
    public int dmPelsWidth; // ディスプレイ解像度（横幅）
    public int dmPelsHeight; // ディスプレイ解像度（高さ）
    public int dmDisplayFlags;
    public int dmDisplayFrequency;
    public int dmICMMethod;
    public int dmICMIntent;
    public int dmMediaType;
    public int dmDitherType;
    public int dmReserved1;
    public int dmReserved2;
    public int dmPanningWidth;
    public int dmPanningHeight;
}
    
[DllImport("user32.dll", CharSet = CharSet.Ansi, SetLastError = true)]
public static extern bool EnumDisplaySettingsA(string lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);
```

ディスプレイの設定項目を全て取る関数なので構造体が長いですが、そのうち使うのは``dmPelsWidth``と``dmPelsHeight``です。``dmPositionX``と``dmPositionY``あたりも何かに使えるのかもしれません。

```
> モニタサイズ:1920x1280
> 画面解像度: 960x640
> 拡大率: 200%
```
よくできました！

これを使って正しいスクリーンショットを撮るように修正しておきましょう。

```CSharp
//using System.Drawing;
//using System.Drawing.Imaging;
//using System.Windows.Forms;
private void button1_Click(object sender, EventArgs e)
{
    // 現在のマウスカーソルがあるスクリーンを取得
    Screen currentScreen = Screen.FromPoint(Cursor.Position);    
    // スクリーンのサイズを取得
    Rectangle screenBounds = currentScreen.Bounds;
    // スクリーンの拡大率を取得
    int scale = getScaleOfScreen(currentScreen);
    // 転送元サイズを変更
    Size srcSize = new Size(screenBounds.Width * scale /100 , screenBounds.Height * scale /100);
    // Bitmapオブジェクトを転送元サイズと同じサイズで作成
    using (Bitmap bitmap = new Bitmap(srcSize.Width, srcSize.Height))
    {
        // グラフィックスオブジェクトを作成
        using (Graphics g = Graphics.FromImage(bitmap))
        {
            // 画面全体をキャプチャ
            g.CopyFromScreen(screenBounds.Location, Point.Empty, srcSize);
        }
        // PNG形式で画像を保存
        bitmap.Save("ss.png", ImageFormat.Png);
    }
}
```

これで、モニターに応じた拡大率でスナップショットを撮れます。

----

以下のブログの内容を参考にしました。ありがとうございます。
[黄昏のスペシャルパンダのブログ：C#で拡大率が違うマルチディスプレイ１画面キャプチャ](https://angelpinpoint.seesaa.net/article/488129908.html)

----

独学のため正確でない可能性があります。
（っ・ｘ・）っ きゅ