diff --git a/CLAUDE.md b/CLAUDE.md index bcd7a6a..e1dd441 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_03 プレビューメモリリーク(原因1〜3)+派生の Lucam クロススレッド GDI クラッシュ・副モニタ描画飢餓を修正し両プレビューをタイマー駆動化.さらにプレビューのフレームコピーを LockBits で高速化(~25ms→~2.5ms,実効 fps 約2倍) +最新: PLAN_03 プレビューメモリリーク(原因1〜3)+派生の Lucam クロススレッド GDI クラッシュ・副モニタ描画飢餓を修正し両プレビューをタイマー駆動化,フレームコピーを LockBits で高速化(~25ms→~2.5ms).FastClone に仕様ベーステスト追加(UI/並行系は実機 MEMMONITOR 計測で検証) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) diff --git a/TIASshot.Tests/BitmapUtilTests.cs b/TIASshot.Tests/BitmapUtilTests.cs new file mode 100644 index 0000000..7e1f32b --- /dev/null +++ b/TIASshot.Tests/BitmapUtilTests.cs @@ -0,0 +1,348 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// BitmapUtil.FastClone のテスト(ピュアロジック層)。 + /// + /// GUIDE_08: FastClone は Bitmap の画素バッファを LockBits + memcpy で複製する + /// ハードウェア非依存の処理であり,NUnit で仕様どおりの入出力を検証できる。 + /// + /// 対象外について: + /// 本コミット(609baf6・252cde3)の本質である Form1.ShowImage / PreviewMonitor の + /// タイマー駆動描画・_dispBufLock を介したクロススレッド Dispose の所有権モデル・ + /// Lucam コールバックスレッドと UI スレッドの GDI 競合・WM_PAINT/WM_TIMER の + /// スケジューリングは,実メッセージループ・実カメラ・副ディスプレイに依存する + /// ハードウェア結合層(GUIDE_08)であり,NUnit の自動ユニットテストの対象外とする。 + /// これらは実機の MEMMONITOR 計測(TEST_01 参照)で反復検証済み。 + /// フェイクの UI ループを組んでまで検証する脆いテストは作らない。 + /// + [TestFixture] + public class BitmapUtilTests { + + // --------------------------------------------------------------- + // 仕様1・2: サイズ・PixelFormat の一致/全画素一致 + // --------------------------------------------------------------- + + /// + /// FastClone_24bppRgb_サイズとPixelFormatが入力と一致する + /// + [Test] + public void FastClone_24bppRgb_サイズとPixelFormatが入力と一致する() { + using (var src = CreateSolidBitmap(4, 3, PixelFormat.Format24bppRgb, Color.FromArgb(10, 20, 30))) + using (var dst = BitmapUtil.FastClone(src)) { + Assert.AreEqual(src.Width, dst.Width, "幅が一致すること"); + Assert.AreEqual(src.Height, dst.Height, "高さが一致すること"); + Assert.AreEqual(src.PixelFormat, dst.PixelFormat, "PixelFormat が一致すること"); + } + } + + /// + /// FastClone_24bppRgb_全画素が入力と一致する + /// + [Test] + public void FastClone_24bppRgb_全画素が入力と一致する() { + using (var src = CreateGradientBitmap(8, 5, PixelFormat.Format24bppRgb)) + using (var dst = BitmapUtil.FastClone(src)) { + AssertPixelsEqual(src, dst); + } + } + + /// + /// FastClone_32bppArgb_サイズとPixelFormatが入力と一致する + /// + [Test] + public void FastClone_32bppArgb_サイズとPixelFormatが入力と一致する() { + using (var src = CreateSolidBitmap(5, 4, PixelFormat.Format32bppArgb, Color.FromArgb(200, 10, 20, 30))) + using (var dst = BitmapUtil.FastClone(src)) { + Assert.AreEqual(src.Width, dst.Width, "幅が一致すること"); + Assert.AreEqual(src.Height, dst.Height, "高さが一致すること"); + Assert.AreEqual(src.PixelFormat, dst.PixelFormat, "PixelFormat が一致すること"); + } + } + + /// + /// FastClone_32bppArgb_全画素(アルファ含む)が入力と一致する + /// + [Test] + public void FastClone_32bppArgb_全画素が入力と一致する() { + using (var src = CreateGradientBitmap(6, 6, PixelFormat.Format32bppArgb)) + using (var dst = BitmapUtil.FastClone(src)) { + AssertPixelsEqual(src, dst); + } + } + + // --------------------------------------------------------------- + // 仕様3: 入力とは独立した画素バッファを持つ + // --------------------------------------------------------------- + + /// + /// FastClone_複製後に入力をDisposeしても複製は有効なまま読める + /// + [Test] + public void FastClone_複製後に入力をDisposeしても複製は有効なまま読める() { + var src = CreateSolidBitmap(4, 4, PixelFormat.Format24bppRgb, Color.FromArgb(11, 22, 33)); + Bitmap dst = BitmapUtil.FastClone(src); + src.Dispose(); + + try { + Assert.AreEqual(4, dst.Width, "src Dispose 後も dst の幅が読めること"); + Assert.AreEqual(4, dst.Height, "src Dispose 後も dst の高さが読めること"); + + Color px = dst.GetPixel(0, 0); + Assert.AreEqual(11, px.R, "src Dispose 後も dst の画素(R)が読めること"); + Assert.AreEqual(22, px.G, "src Dispose 後も dst の画素(G)が読めること"); + Assert.AreEqual(33, px.B, "src Dispose 後も dst の画素(B)が読めること"); + } finally { + dst.Dispose(); + } + } + + /// + /// FastClone_複製の画素を書き換えても入力の画素は変化しない + /// + [Test] + public void FastClone_複製の画素を書き換えても入力の画素は変化しない() { + using (var src = CreateSolidBitmap(4, 4, PixelFormat.Format24bppRgb, Color.FromArgb(50, 60, 70))) + using (var dst = BitmapUtil.FastClone(src)) { + dst.SetPixel(0, 0, Color.FromArgb(0, 0, 0)); + + Color srcPx = src.GetPixel(0, 0); + Assert.AreEqual(50, srcPx.R, "dst 変更後も src の R は不変"); + Assert.AreEqual(60, srcPx.G, "dst 変更後も src の G は不変"); + Assert.AreEqual(70, srcPx.B, "dst 変更後も src の B は不変"); + } + } + + // --------------------------------------------------------------- + // 仕様4: 入力を破壊しない(呼び出し前後で src の画素が不変) + // --------------------------------------------------------------- + + /// + /// FastClone_呼び出し前後でsrcの画素が変化しない + /// + [Test] + public void FastClone_呼び出し前後でsrcの画素が変化しない() { + using (var src = CreateGradientBitmap(7, 5, PixelFormat.Format24bppRgb)) { + // 呼び出し前の画素を保持 + Color before00 = src.GetPixel(0, 0); + Color beforeCorner = src.GetPixel(src.Width - 1, src.Height - 1); + + using (var dst = BitmapUtil.FastClone(src)) { + Assert.IsNotNull(dst); + } + + Color after00 = src.GetPixel(0, 0); + Color afterCorner = src.GetPixel(src.Width - 1, src.Height - 1); + + Assert.AreEqual(before00, after00, "FastClone 呼び出し前後で (0,0) の画素が不変であること"); + Assert.AreEqual(beforeCorner, afterCorner, "FastClone 呼び出し前後で末尾画素が不変であること"); + } + } + + // --------------------------------------------------------------- + // 仕様5: ストライドのパディング(幅が4の倍数でない24bppRgb) + // --------------------------------------------------------------- + + /// + /// FastClone_幅が4の倍数でない24bppRgb_全画素が一致する(行末パディングを除外して比較) + /// + [Test] + public void FastClone_幅が4の倍数でない24bppRgb_全画素が一致する() { + // 幅5px * 3byte = 15byte → 4byte境界にするため1byteパディングが入るサイズ + using (var src = CreateGradientBitmap(5, 3, PixelFormat.Format24bppRgb)) + using (var dst = BitmapUtil.FastClone(src)) { + AssertPixelsEqual(src, dst); + } + } + + /// + /// FastClone_幅が4の倍数でない24bppRgb_サイズが一致する + /// + [Test] + public void FastClone_幅が4の倍数でない24bppRgb_サイズが一致する() { + using (var src = CreateGradientBitmap(5, 3, PixelFormat.Format24bppRgb)) + using (var dst = BitmapUtil.FastClone(src)) { + Assert.AreEqual(5, dst.Width); + Assert.AreEqual(3, dst.Height); + } + } + + // --------------------------------------------------------------- + // 仕様6: インデックスカラーのフォールバック + // --------------------------------------------------------------- + + /// + /// FastClone_8bppIndexed_色が等価な独立コピーを返す: + /// + /// 8bppIndexed は LockBits 単純コピーではパレットを保持できないため, + /// `new Bitmap(src)`(GDI+ フルコピー)にフォールバックする。 + /// このフォールバックは GDI+ の仕様上,返り値の PixelFormat を + /// Format32bppArgb に変換する(Format8bppIndexed のままにはならない)。 + /// そのため本テストでは PixelFormat の一致は検証せず, + /// 「例外を投げない」「入力とは別インスタンス」「サイズが一致する」 + /// 「解決済みの色(GetPixel が返すパレット適用後の ARGB)が画素ごとに一致する」 + /// という,このフォールバックが満たすべき妥当な契約を検証する。 + /// + [Test] + public void FastClone_8bppIndexed_色が等価な独立コピーを返す() { + using (var src = new Bitmap(4, 4, PixelFormat.Format8bppIndexed)) { + // パレットを設定(グレースケール) + ColorPalette palette = src.Palette; + for (int i = 0; i < palette.Entries.Length; i++) { + palette.Entries[i] = Color.FromArgb(i, i, i); + } + src.Palette = palette; + + // 画素インデックスを設定 + SetIndexedPixel(src, 0, 0, 5); + SetIndexedPixel(src, 1, 1, 200); + SetIndexedPixel(src, 3, 3, 255); + + Bitmap dst = null; + Assert.DoesNotThrow(() => dst = BitmapUtil.FastClone(src), + "8bppIndexed を渡しても例外をスローしないこと"); + + try { + Assert.IsNotNull(dst, "8bppIndexed のフォールバックコピーは null であってはならない"); + Assert.AreNotSame(src, dst, "入力とは別インスタンスであること"); + Assert.AreEqual(src.Width, dst.Width, "幅が一致すること"); + Assert.AreEqual(src.Height, dst.Height, "高さが一致すること"); + + // 解決済みの色(パレット適用後の ARGB)が画素ごとに一致すること + Assert.AreEqual(src.GetPixel(0, 0), dst.GetPixel(0, 0), "(0,0) の色が一致すること"); + Assert.AreEqual(src.GetPixel(1, 1), dst.GetPixel(1, 1), "(1,1) の色が一致すること"); + Assert.AreEqual(src.GetPixel(3, 3), dst.GetPixel(3, 3), "(3,3) の色が一致すること"); + + // 独立性: src を Dispose 後も dst の画素が読めること + } finally { + if (dst != null) dst.Dispose(); + } + } + } + + /// + /// FastClone_8bppIndexed_複製後に入力をDisposeしても複製の色は読める: + /// インデックスフォールバック経路でも独立性(クロススレッド Dispose 耐性)が + /// 保たれることを確認する。 + /// + [Test] + public void FastClone_8bppIndexed_複製後に入力をDisposeしても複製の色は読める() { + var src = new Bitmap(2, 2, PixelFormat.Format8bppIndexed); + ColorPalette palette = src.Palette; + palette.Entries[9] = Color.FromArgb(9, 9, 9); + src.Palette = palette; + SetIndexedPixel(src, 0, 0, 9); + + Bitmap dst = BitmapUtil.FastClone(src); + Color expected = src.GetPixel(0, 0); + src.Dispose(); + + try { + Color actual = dst.GetPixel(0, 0); + Assert.AreEqual(expected, actual, "src Dispose 後も dst の色が読めること"); + } finally { + dst.Dispose(); + } + } + + // --------------------------------------------------------------- + // 仕様7: null 入力 + // --------------------------------------------------------------- + + /// + /// FastClone_null入力_nullを返す + /// + [Test] + public void FastClone_null入力_nullを返す() { + Bitmap result = BitmapUtil.FastClone(null); + Assert.IsNull(result, "null 入力に対して null を返すこと(例外をスローしない)"); + } + + // --------------------------------------------------------------- + // ヘルパー + // --------------------------------------------------------------- + + /// + /// 単色の Bitmap を生成する + /// + private static Bitmap CreateSolidBitmap(int width, int height, PixelFormat format, Color color) { + var bmp = new Bitmap(width, height, format); + using (var g = Graphics.FromImage(bmp)) { + using (var brush = new SolidBrush(color)) { + g.FillRectangle(brush, 0, 0, width, height); + } + } + return bmp; + } + + /// + /// 決定的なグラデーション(座標に依存する画素値)を持つ Bitmap を GetPixel/SetPixel で生成する。 + /// GDI+ の描画経路を通さず,画素値を直接指定するため決定的。 + /// + private static Bitmap CreateGradientBitmap(int width, int height, PixelFormat format) { + var bmp = new Bitmap(width, height, format); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int r = (x * 37 + y * 11) % 256; + int g = (x * 5 + y * 53) % 256; + int b = (x * 71 + y * 3) % 256; + int a = format == PixelFormat.Format32bppArgb ? (x * 13 + y * 17) % 256 : 255; + bmp.SetPixel(x, y, Color.FromArgb(a, r, g, b)); + } + } + return bmp; + } + + /// + /// 8bppIndexed Bitmap の指定座標にパレットインデックスを設定する(LockBits 経由) + /// + private static void SetIndexedPixel(Bitmap bmp, int x, int y, byte index) { + var rect = new Rectangle(x, y, 1, 1); + BitmapData data = bmp.LockBits(rect, ImageLockMode.WriteOnly, bmp.PixelFormat); + try { + System.Runtime.InteropServices.Marshal.WriteByte(data.Scan0, index); + } finally { + bmp.UnlockBits(data); + } + } + + /// + /// 2つの Bitmap が全画素で一致することを検証する(GetPixel による代表的座標比較でなく全画素走査)。 + /// LockBits で生バイトを取得し,行ごとに有効バイト幅(パディング除外)だけを比較する。 + /// + private static void AssertPixelsEqual(Bitmap expected, Bitmap actual) { + Assert.AreEqual(expected.Width, actual.Width, "幅が一致すること(画素比較の前提)"); + Assert.AreEqual(expected.Height, actual.Height, "高さが一致すること(画素比較の前提)"); + Assert.AreEqual(expected.PixelFormat, actual.PixelFormat, "PixelFormat が一致すること(画素比較の前提)"); + + var rect = new Rectangle(0, 0, expected.Width, expected.Height); + BitmapData expData = expected.LockBits(rect, ImageLockMode.ReadOnly, expected.PixelFormat); + BitmapData actData = actual.LockBits(rect, ImageLockMode.ReadOnly, actual.PixelFormat); + try { + int bytesPerPixel = Image.GetPixelFormatSize(expected.PixelFormat) / 8; + int validBytesPerRow = expected.Width * bytesPerPixel; + + var expRow = new byte[validBytesPerRow]; + var actRow = new byte[validBytesPerRow]; + + for (int y = 0; y < expected.Height; y++) { + System.Runtime.InteropServices.Marshal.Copy( + IntPtr.Add(expData.Scan0, y * expData.Stride), expRow, 0, validBytesPerRow); + System.Runtime.InteropServices.Marshal.Copy( + IntPtr.Add(actData.Scan0, y * actData.Stride), actRow, 0, validBytesPerRow); + + for (int i = 0; i < validBytesPerRow; i++) { + Assert.AreEqual(expRow[i], actRow[i], + $"行 y={y} のバイト位置 {i}(パディング除外後)が一致すること"); + } + } + } finally { + expected.UnlockBits(expData); + actual.UnlockBits(actData); + } + } + } +} diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index c3cb763..829bc8b 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -45,6 +45,7 @@ + @@ -54,6 +55,7 @@ + diff --git a/TIASshot/Utils/BitmapUtil.cs b/TIASshot/Utils/BitmapUtil.cs index 92eb2c6..82d10b6 100644 --- a/TIASshot/Utils/BitmapUtil.cs +++ b/TIASshot/Utils/BitmapUtil.cs @@ -32,6 +32,8 @@ // インデックスカラー系(8bpp 以下)はパレットを保持する必要があり, // LockBits の単純コピーだけではパレット情報が失われるため, // 安全網として従来どおりの GDI+ フルコピーにフォールバックする。 + // ※ この経路は入力と独立・色が等価なコピーを返すが,GDI+ の Bitmap(Image) + // により PixelFormat は 32bppArgb に変換される(8bppIndexed のままにはならない)。 // プレビュー画像(Mat.ToBitmap() 由来の 24bppRgb)は通常この分岐に入らない。 if ((src.PixelFormat & PixelFormat.Indexed) != 0) { return new Bitmap(src); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 0890c56..e37af13 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -88,4 +88,5 @@ - [x] **プレビューのフレームコピーを LockBits で高速化** (2026-07-11): プレビュー表示経路で毎フレーム生成する Bitmap コピー(`new Bitmap(bmp)`)が 1 回 ~25ms かかり、コールバックスレッドの律速で実効 ~10fps に頭打ちだった問題を、生画素の直接コピーに置換して解消。実機計測で コピー ~25ms→~2.5ms(約10倍)、`callback_fps` ~16→~30、両プレビュー描画 ~10→~20fps を確認。 - 原因: `new Bitmap(Image)` は内部で 32bppARGB への変換を伴う GDI+ DrawImage 経路を通るため遅い。プレビューは 24bppRgb(`Mat.ToBitmap()` 由来)で、同一フォーマットの生画素をそのままコピーすれば足りる。 - - 方針: `LockBits` で src/dst の生画素ポインタを取得し `RtlMoveMemory`(kernel32)で行単位ネイティブコピーする `FastClone`(`/unsafe` 不要)。生成物は入力と独立した画素バッファ=所有権・スレッド安全性(Lucam クロススレッド Dispose と非衝突)は従来の `new Bitmap(bmp)` と同一で不変。src は ReadOnly ロックのみで非破壊。インデックスカラー(8bpp 以下・パレット保持要)は安全網として従来の GDI+ フルコピーにフォールバック。 + - 方針: `LockBits` で src/dst の生画素ポインタを取得し `RtlMoveMemory`(kernel32)で行単位ネイティブコピーする `FastClone`(`/unsafe` 不要)。生成物は入力と独立した画素バッファ=所有権・スレッド安全性(Lucam クロススレッド Dispose と非衝突)は従来の `new Bitmap(bmp)` と同一で不変。src は ReadOnly ロックのみで非破壊。インデックスカラー(8bpp 以下・パレット保持要)は安全網として従来の GDI+ フルコピーにフォールバック(この経路は色は等価だが PixelFormat が 32bppArgb に変換される点に注意)。 + - テスト方針(本 PLAN_03 一連の実装共通): 自動 NUnit テストで担保できるのは純ロジックの `FastClone` のみ(独立コピー・src 非破壊・ストライドパディング・Indexed フォールバック・null)。プレビューのタイマー駆動描画・`_dispBufLock` を介したクロススレッド Dispose の所有権・WM_PAINT/WM_TIMER のスケジューリングは UI スレッド/GDI/実カメラ・副ディスプレイ結合層でユニットテスト不能のため、実機 MEMMONITOR 計測(コピー時間・各段 FPS・GDI/handle・描画時間の CSV)で反復検証した。