【PowerShell】ただ画像をトリミングしたいだけなのにマーシャリングを知らなくて沼った話

Code

はじまり

リサちゃん
リサちゃん

なんだこれ生成AIに聞いても全然わからないよぉぉ!??

135ml
135ml

初見殺し的なところはあるな。

身に覚えのない型によるエラー

こんな感じのコードを実行した時に、最終行でとあるエラーが発生しました。

$srcBmp = New-Object System.Drawing.Bitmap($srcBmpAbsName);
$width = $srcBmp.Width;
$heightUnit = [Math]::Round($width * $AspectRatioOfLegal, 0, [MidpointRounding]::AwayFromZero);
$dstRect = New-Object System.Drawing.Rectangle (0, (($increment - 1) * $heightUnit), $width, $heightUnit);

エラーメッセージ

"System.Object[]" の値を "System.Object[]" 型から "System.UInt32" 型に変換できません。

はて?

System.Drawing.Rectangleの引数には数値型の変数しか入れていません。なのに「System.Object[]」?

それともたった今New-Objectで変数を代入して出来上がったものに対してなんか言ってる? 果たしてどの部分が問題なのか全然わからない・・・。あー、PowerShell終わったなーと思いました。

よく分からないので、とりあえずキャストして再び実行してみます。

$dstRect = New-Object System.Drawing.Rectangle ([int]0, [int](($increment - 1) * $heightUnit), [int]$width, [int]$heightUnit);

エラーメッセージ

"System.Object[]" の値を "System.Object[]" 型から "System.UInt32" 型に変換できません。

はぁぁ・・・?

解決法

今回のバグは、「.NET Framework」という外部ライブラリの作法に則っていないことが原因でした。このライブラリのSystem.Drawing.Rectangle構造体は、引数にSystem.UInt32の型の数値を渡さなければなりません。

なので、引数を渡す時にこのようにキャストすれば直りました。

$srcRect = New-Object System.Drawing.Rectangle ([System.UInt32]0, [System.UInt32]0, [System.UInt32]$srcBmp.Width, [System.UInt32]$srcBmp.Height);

上記のように、外部モジュールの作法に則ってデータを整形して渡すことを、「マーシャリング」と呼ぶそうです。

マーシャリングは、「整形、整頓」といった意味を持つ単語です。(そういえば以前にGoを触っていた時に、json.Marshalとかjson.Unmarshalとかいうメソッドがあったから、JSONを「シリアライズ」するのと似たような感じなのだろうか。)

型のマーシャリング - .NET
.NET が型をネイティブ表現にマーシャリングする方法について説明します。

あー、良かったよぉぉぉ。

ということで、トリミング処理を実装する。

ここを乗り越えればとりあえずは実装できそう。

と思ったんですけど、今回初めてPowerShellで画像のトリミング処理を実装するので、その後の処理もそれなりに調べて実装していきます。(以前にPythonのPillowライブラリで、画像を生成する処理を書いた時はこんなに考えなかったような・・・。)

今回は以下のような、画像のトリミングツールを構成するコードが出来ました。この処理は、縦にとても長~い画像ファイルを任意の長さに切り分けるものになっています。イメージとしては、かまぼことか伊達巻を輪切りにしていくみたいな感じです。

# Load assembly of .NET Framework
Add-Type -AssemblyName System.Drawing;

$srcBmpAbsName = $Args[0];
$srcFile = Get-Item -Path $srcBmpAbsName;
$srcBmp = New-Object System.Drawing.Bitmap($srcBmpAbsName);
$srcRect = New-Object System.Drawing.Rectangle ([System.UInt32]0, [System.UInt32]0, [System.UInt32]$srcBmp.Width, [System.UInt32]$srcBmp.Height);
$srcBmpData = $srcBmp.LockBits($srcRect, [Drawing.Imaging.ImageLockMode]::ReadOnly, $srcBmp.PixelFormat);
try {
  $isLast = $false;
  $width = $srcBmpData.Width;
  $AspectRatioOfLegal = 356 / 216;
  $heightUnit = [Math]::Round($width * $AspectRatioOfLegal, 0, [MidpointRounding]::AwayFromZero);
  $clippingHeight = $heightUnit;

  Write-Host ("{0}: aaaaaaaaaaaaaaaaaaaaaaaaaaaaa`n" -f $MyInvocation.MyCommand.Name);
  Write-Output $srcBmpAbsName;
  Write-Host ("{0}: bbbbbbbbbbbbbbbbbbbbbbbbbbbbb`n" -f $MyInvocation.MyCommand.Name);
  Write-Output $srcBmp;
  Write-Host ("{0}: cccccccccccccccccccccccccccccc`n" -f $MyInvocation.MyCommand.Name);
  Write-Output (Get-Location).Path;
  Write-Output $srcFile.BaseName;
  Write-Output $srcFile.Extension;

  for ($increment = 0; $increment -lt 100; $increment++) {
    if (($increment) * $heightUnit + $heightUnit -gt $srcBmp.Height) {
      $clippingHeight = $srcBmp.Height - ($increment) * $heightUnit;
      $isLast = $true;
    }
    Write-Output $srcBmp.Height;
    Write-Host ("{0}: iiiiiiiiiiiiiiiiiiiiiiiiiiiii`n" -f $MyInvocation.MyCommand.Name);
    Write-Output ((($increment) * $heightUnit).GetType().FullName)
    Write-Output ($width.GetType().FullName)
    Write-Output ($heightUnit.GetType().FullName)
    Write-Output ($heightUnit)

    # Set an area to trim and clone an image.
    # $dstRect = New-Object System.Drawing.Rectangle ([int]0, [int]($increment) * $heightUnit, [int]$width, [int]$heightUnit);
    $dstRect = New-Object System.Drawing.Rectangle ([System.UInt32]0, [System.UInt32]($increment * $heightUnit), [System.UInt32]$width, [System.UInt32]$clippingHeight);

    # Trim an image and save
    $dstBmpName = "{0}\{1}_{2:D2}{3}" -f (Get-Location).Path, $srcFile.BaseName, [int]($increment + 1), $srcFile.Extension;
    $dstBmp.Save($dstBmpName, [System.Drawing.Imaging.ImageFormat]::Png);
    Write-Host ("{0}: mmmmmmmmmmmmmmmmmmmmmmmmmmmmmm`n" -f $MyInvocation.MyCommand.Name);
    Write-Output $dstBmpName;
    Write-Host ("{0}: nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn`n" -f $MyInvocation.MyCommand.Name);
    Write-Output $dstBmp;

    # $dstRect.Dispose();
    $dstBmp.Dispose();
    if ($isLast -eq $true) {
      break;
    }
  }
}
catch {
  Write-Host "Error occured.....";
  Write-Host $_ -BackgroundColor DarkRed;
  $tmp = Read-Host "Input 'y' to close this process......";
}
finally {
  $srcBmp.UnlockBits($srcBmpData);
  $srcBmp.Dispose();
}

$tmp = Read-Host "Input 'y' if you wanna move original images......";

BitmapをDisposeする

BitmapのDisposeを忘れるべからず

今回、画像のトリミング処理をするに当たって、.NET FrameworkのSystem.Drawing.Bitmapクラスを使っていきます。

さて、このクラスを使うに当たって、このクラスで新しいオブジェクトを作って何らかの処理で使った後にそのままスクリプトを終わらせてはいけません。なぜなら、このSystem.Drawing.Bitmapによって出現したオブジェクト(GDI+)はプロセス終了後にもメモリの中に残ってしまうためです。

# これでBitmapオブジェクトを作ったら、
$srcBmp = New-Object System.Drawing.Bitmap($srcBmpAbsName);

# 破棄しなければならない。
$srcBmp.Dispose();

ちなみにこの「GDI+」は、「グラフィックス デバイス インターフェイス」というヤツらしく、アプリとデバイスドライバーの間を取り次ぐ仕事をしてくれるそうです。しかし、トリミング処理が終わったら君には退散してもらわなければならない・・・。働かざるもの食うべからず。

GDI+ - Win32 apps
Windows GDI+ は、C/C++ プログラマ向けのクラスベースの API です。

GDI+ で汎用エラーが発生しました。?

先程のGDI+オブジェクトの破棄を忘れたり、実行中に割り込み終了をしたりすると、次回に同処理を実行した時にこんな感じのエラーメッセージが表示されるようになってしまいます。

“1” 個の引数を指定して “UnlockBits” を呼び出し中に例外が発生しました: “GDI+ で汎用エラーが発生しました。”

このエラーメッセージは、GDIオブジェクトの数がシステム上限の10,000個を超えたときに発生するものらしいです。一度、Dispose()を忘れるとこれが表示され続けるのは煩わしいですね・・・。

Just a moment...

じゃあ、破棄されなかったGDIオブジェクトがどのプロセスで使われているかを確認したくなってきました。

そのために、タスクマネージャーを起動して、「詳細」→ヘッダーをクリック。→「列の選択」→「GDIオブジェクト」を選択してOK。の流れで・・・・・・確認できると思ったんですけど・・・、

と言うのも、タスクマネージャーを見ただけでは、PowerShellで起動したプロセスのどれが当てはまるのかを区別することが出来ませんでした。既にプロセスが存在しないとも言えそう・・・。(じゃあ、裏でGDIオブジェクトが10,000個以上あるってこと・・・? ちょっと分からないなあ。)

そういえば話は変わりますが、以前にPythonで書いた時はimg.save()みたいな感じで関数を書き終えた覚えがある・・・。その画像もちゃんとBitmap形式だったから破棄する必要が・・・うーんまあいいか・・・。一応ガベージコレクションあるし・・・。まあ、良くはなかったか・・・。

エラーハンドリングで、Disposeを忘れない。

なんだか、Bitmapクラスのオブジェクトの破棄を忘れると厭わしいことになる事が分かったので、途中で処理に失敗しても間違いなくDisposeするようにしたいと思います。

そこで、try-catch-finallyステートメントをちゃんと用意して処理を入れ込みます。

# Load assembly of .NET Framework
Add-Type -AssemblyName System.Drawing;

$srcBmpAbsName = $Args[0];
$srcFile = Get-Item -Path $srcBmpAbsName;
$srcBmp = New-Object System.Drawing.Bitmap($srcBmpAbsName);
$srcRect = New-Object System.Drawing.Rectangle ([System.UInt32]0, [System.UInt32]0, [System.UInt32]$srcBmp.Width, [System.UInt32]$srcBmp.Height);
$srcBmpData = $srcBmp.LockBits($srcRect, [Drawing.Imaging.ImageLockMode]::ReadOnly, $srcBmp.PixelFormat);
try {
  # トリミング処理
}
catch {
  Write-Host "Error occured.....";
  Write-Host $_ -BackgroundColor DarkRed;
  $tmp = Read-Host "Input 'y' to close this process......";
}
finally {
  $srcBmp.UnlockBits($srcBmpData);
  $srcBmp.Dispose();
}

色々書きましたが、結局のところは.NET FrameworkのSystem.Drawing.BitmapクラスをDispose()するのを忘れないように気を付けるということです。忘れたらPCを再起動でGDIオブジェクトは消せるでしょう。

RectangleはDispose出来ない

なので、同じく.NET FrameworkのSystem.Drawing名前空間にいるSystem.Drawing.Rectangle構造体もDispose()する必要があるのかと言うと、おそらく必要なさそう。

なぜなら、System.Drawing.Rectangle構造体を作る時には、GDI+を作らないからです。たぶん。以下のRectangle構造体に関するドキュメントには、「GDI」の文字は存在しなかった。

Rectangle 構造体 (System.Drawing)
四角形の位置とサイズを表す 4 つの整数のセットを格納します。

ちなみにDispose()しようとすると、こんな感じのエラーメッセージが表示されます。

[System.Drawing.Rectangle] に ‘Dispose’ という名前のメソッドが含まれないため、メソッドの呼び出しに失敗しました。

Bitmap.LockBitsで処理を軽くする。

それから、System.Drawing.Bitmapクラスのオブジェクトをいちいち参照すると、処理が重くなるようです。こちらが参考記事です。

.NETによる画像処理の高速化Tips:非unsafe編 - Qiita
はじめに.NETのBitmapオブジェクトを使って画像のフィルター処理や変換などの画像処理をする際、高速に処理するためにはいくつかのお作法的なTipsがあります。ここでは、良く知られているTip…

どうやら、BitmapオブジェクトのGetPixel()メソッドに紐づく処理を実行すると、動作が遅くなってしまうみたいなのです。そこで、このBitmap.LockBits()メソッドで、Bitmapのピクセルデータをバッファに一時保存して、そのデータから情報を参照することで処理が軽くなるみたいです。

$srcBmp = New-Object System.Drawing.Bitmap($srcBmpAbsName);
$srcRect = New-Object System.Drawing.Rectangle ([System.UInt32]0, [System.UInt32]0, [System.UInt32]$srcBmp.Width, [System.UInt32]$srcBmp.Height);
$srcBmpData = $srcBmp.LockBits($srcRect, [Drawing.Imaging.ImageLockMode]::ReadOnly, $srcBmp.PixelFormat);

try {

  $width = $srcBmpData.Width;
  $heightUnit = [Math]::Round($width * $AspectRatioOfLegal, 0, [MidpointRounding]::AwayFromZero);
  $clippingHeight = $heightUnit;

  for ($increment = 0; $increment -lt 100; $increment++) {
    if (($increment) * $heightUnit + $heightUnit -gt $srcBmp.Height) {
      $clippingHeight = $srcBmp.Height - ($increment) * $heightUnit;
      $isLast = $true;
    }

    $dstRect = New-Object System.Drawing.Rectangle ([System.UInt32]0, [System.UInt32]($increment * $heightUnit), [System.UInt32]$width, [System.UInt32]$clippingHeight);
    $dstBmp = $srcBmp.Clone($dstRect, $srcBmpData.PixelFormat);
    $dstBmpName = "{0}\{1}_{2:D2}{3}" -f (Get-Location).Path, $srcFile.BaseName, [int]($increment + 1), $srcFile.Extension;
    $dstBmp.Save($dstBmpName, [System.Drawing.Imaging.ImageFormat]::Png);

    $dstBmp.Dispose();
    if ($isLast -eq $true) {
      break;
    }
  }
}
Bitmap::LockBits (gdiplusheaders.h) - Win32 apps
Bitmap::LockBits メソッドは、このビットマップの四角形の部分をロックし、指定した形式でピクセル データの読み取りまたは書き込みに使用できる一時的なバッファーを提供します。

そして、Bitmap.LockBits()メソッドを使ったプロセスでは、Bitmap.UnlockBits()メソッドで、メモリ上にロックされていたビットマップのロックを解除する必要があります。Dispose()と同時期に書いておきます。

finally {
  $srcBmp.UnlockBits($srcBmpData);
  $srcBmp.Dispose();
}

実際にツールを実行してみますよ。

実際にこのトリミングツールを使用すると、この縦に長~い画像が・・・、

このように輪切りされました。この画像の場合は5枚に分かれました。

まとめ

PowerShellを使って、大きな画像を小分けにトリミングするツールを、実装する方法を紹介しました。

  • .NET FrameworkのSystem.Drawing.Rectangle構造体の引数には、System.UInt32の型の数値を渡さなければならない。
  • 外部ライブラリ、ランタイム等に対して渡すデータを整形する作業を、「マーシャリング」と呼んだりする。
  • System.Drawing.Bitmapクラスには、Dispose()を忘れるべからず。忘れると、GDI+というオブジェクトがメモリに残る。
  • Bitmap.LockBits()メソッドで、バッファにデータを一時保存することで、Bitmapへの処理が軽くなる。

PowerShellはWindows上にプリインストールされているので、誰でもこの画像トリミングの処理は実行できます。よければ試してみて下さい。

PowerShell関連記事

その他のPowerShell関連の記事を貼っておきます。

おしまい

リサちゃん
リサちゃん

よし、これで画像を整理できる!

135ml
135ml

もっと手早く終わらせるつもりだったのになあ・・・。

以上になります!

コメント

タイトルとURLをコピーしました