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)で反復検証した。