diff --git a/CLAUDE.md b/CLAUDE.md index e375fcf..e1dd441 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 メモリ修正の効果検証に「連続撮影フロー再現ベンチ」(合成フレーム・生産者消費者+多チャンネル)を追加し計測結果を TEST_01 に記録 +最新: 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/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index de879d6..e521dc2 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -185,6 +185,9 @@ LogCalibrationConverged(); CalcTcc(imgt); RecordCalibrationInfo(imgt); +#if MEMMONITOR + PreviewProfiler.MarkEvent("CALIB_END"); +#endif } } @@ -204,6 +207,9 @@ /// 撮影間隔(ms) protected void RunShotLoop(int numImages, int interval) { try { +#if MEMMONITOR + PreviewProfiler.MarkEvent("SHOT_START"); +#endif var filename = Config.GetString("File/Info"); using (var csv = new StreamWriter(Path.Combine(_saveFolder, filename))) { WriteInfo(csv); @@ -222,6 +228,9 @@ } } finally { _shots.CompleteAdding(); +#if MEMMONITOR + PreviewProfiler.MarkEvent("SHOT_END"); +#endif } } @@ -237,6 +246,9 @@ _chartMasks.Clear(); _chartMasks.AddRange(masks); _calibrating = Config.GetInt("Calib/Frames"); +#if MEMMONITOR + PreviewProfiler.MarkEvent("CALIB_START"); +#endif } /// diff --git a/TIASshot/Cameras/IScam.cs b/TIASshot/Cameras/IScam.cs index e50b8ba..f6e3eef 100644 --- a/TIASshot/Cameras/IScam.cs +++ b/TIASshot/Cameras/IScam.cs @@ -108,7 +108,11 @@ using (Mat img = Mat.FromPixelData(frameType.Height, frameType.Width, MatType.CV_8UC3, buffer.GetIntPtr())) { using (Mat imgt_full = img.T()) { using (var imgt = new Mat(imgt_full, _roi)) { - _form.ShowImage(imgt.ToBitmap()); + // ShowImage は入力 Bitmap を読むだけ(表示用には内部でコピーを生成する)ため, + // 呼び出し側で所有権を持ち,using で確実に Dispose する + using (var frameBmp = imgt.ToBitmap()) { + _form.ShowImage(frameBmp); + } ProcessCalibrationFrame(imgt); } } diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index d78ffc0..3aae01d 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -124,6 +124,7 @@ + @@ -144,6 +145,8 @@ MemoryMonitorForm.cs + + diff --git a/TIASshot/UI/Form1.Designer.cs b/TIASshot/UI/Form1.Designer.cs index b69b7c0..c17a308 100644 --- a/TIASshot/UI/Form1.Designer.cs +++ b/TIASshot/UI/Form1.Designer.cs @@ -346,7 +346,6 @@ this.Text = "Form1"; this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.Form1_FormClosing); this.Load += new System.EventHandler(this.Form1_Load); - this.Paint += new System.Windows.Forms.PaintEventHandler(this.Form1_Paint); ((System.ComponentModel.ISupportInitialize)(this.picPreview)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.picDisplay)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.icImagingControl1)).EndInit(); diff --git a/TIASshot/UI/Form1.MemMonitor.cs b/TIASshot/UI/Form1.MemMonitor.cs index c341f94..585c92c 100644 --- a/TIASshot/UI/Form1.MemMonitor.cs +++ b/TIASshot/UI/Form1.MemMonitor.cs @@ -6,6 +6,10 @@ private MemoryMonitorForm _memoryMonitorForm; + // F9 で ON/OFF をトグルする副モニタ計測用の一時抑制フラグ。 + // true の間は ShowImage が副モニタへのフレーム給餌(コピー生成・UpdateImage 呼び出し)を止める。 + private bool _subMonitorSuppressed = false; + /// /// メモリ観測ウィンドウを非モーダルで開く(MEMMONITOR 限定)。 /// 既に開いている場合は前面化する。 @@ -27,6 +31,34 @@ _memoryMonitorForm.Show(this); } + + /// + /// 副モニタへのプレビュー給餌を ON/OFF トグルする(MEMMONITOR 限定・計測用)。 + /// F9 キーで Form1・MemoryMonitorForm のどちらからでも呼び出せる。 + /// 副ディスプレイが存在しない環境(_previewMonitor.IsShown が false)では何もしない。 + /// + public void ToggleSubMonitorProfiling() { + if (_previewMonitor == null || !_previewMonitor.IsShown) return; // 副ディスプレイが無い環境では何もしない + _subMonitorSuppressed = !_subMonitorSuppressed; + if (_subMonitorSuppressed) { + _previewMonitor.Hide(); + PreviewProfiler.MarkEvent("SUB_OFF"); + } else { + _previewMonitor.Show(); + PreviewProfiler.MarkEvent("SUB_ON"); + } + } + + /// + /// F9 キーで副モニタ給餌をトグルする(MEMMONITOR 限定)。 + /// Form1_Load で KeyPreview = true を設定した上で KeyDown に接続する。 + /// + private void Form1_MemMonitor_KeyDown(object sender, KeyEventArgs e) { + if (e.KeyCode == Keys.F9) { + ToggleSubMonitorProfiling(); + e.Handled = true; + } + } } } #endif diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index 152a677..accbd62 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -24,6 +24,16 @@ private PreviewMonitor _previewMonitor; private Bitmap _dispBuf = null; private bool _isUpdateBuf = false; + private readonly object _dispBufLock = new object(); + + // 本モニタ(picDisplay)の自ペース再描画用タイマー。 + // 以前はコールバックスレッドから毎フレーム Invalidate() していたが,Form1 の WM_PAINT が + // 常時保留状態になり,同一 UI スレッドを共有する副モニタの WM_TIMER が + // (WM_PAINT > WM_TIMER の優先度により)飢餓状態になっていた。 + // 本モニタも副モニタと同じタイマー駆動に統一し,コールバックスレッドからは + // Invalidate を一切呼ばないことで WM_PAINT の常時保留(洪水源)を消す。 + private const int MainRefreshIntervalMs = 33; // ≒30fps(PreviewMonitor と同じ値) + private readonly System.Windows.Forms.Timer _mainRefreshTimer = new System.Windows.Forms.Timer(); /// @@ -36,6 +46,8 @@ #endif #if MEMMONITOR this.Shown += (s, e) => ShowMemoryMonitor(); + this.KeyPreview = true; + this.KeyDown += Form1_MemMonitor_KeyDown; #endif var version = Assembly.GetExecutingAssembly().GetName().Version; @@ -97,6 +109,10 @@ _previewMonitor = new PreviewMonitor(); if (_previewMonitor.IsShown) _previewMonitor.Show(); + + _mainRefreshTimer.Interval = MainRefreshIntervalMs; + _mainRefreshTimer.Tick += MainRefreshTimer_Tick; + _mainRefreshTimer.Start(); } /// @@ -105,6 +121,7 @@ /// /// private void Form1_FormClosing(object sender, FormClosingEventArgs e) { + _mainRefreshTimer.Stop(); if (_camera == null) return; _camera.Disconnect(); } @@ -144,27 +161,90 @@ /// /// public void ShowImage(Bitmap bmp) { - var bmp2 = new Bitmap(bmp); - _dispBuf = bmp; - _isUpdateBuf = true; - Invalidate(); +#if MEMMONITOR + PreviewProfiler.IncrementCallbackFrames(); + PreviewProfiler.SetLastImageSize(bmp.Width, bmp.Height); + long mainCopyStart = System.Diagnostics.Stopwatch.GetTimestamp(); +#endif + // Lucam 経路は二重バッファ(_bmps[0]/_bmps[1])をコールバックスレッド上で Dispose するため, + // 入力 bmp をそのまま picDisplay.Image に載せると,UIスレッドの OnPaint が Width 等を + // 読んでいる最中にコールバックスレッドが Dispose して別スレッド同時アクセスでクラッシュしうる。 + // そのため ShowImage は入力 bmp を「読むだけ」とし,表示用には呼び出しスレッド上で + // Form1 所有のコピーを生成する(bmp の所有権は呼び出し側に残す)。 + // BitmapUtil.FastClone は LockBits による生画素コピーで,GDI+ の DrawImage 経路を使う + // new Bitmap(bmp)(~25ms)より高速(数 ms)。生成されるコピーは入力と独立した画素バッファを + // 持つため,所有権・スレッド安全性のモデルは new Bitmap(bmp) と完全に同一。 + var dispCopy = BitmapUtil.FastClone(bmp); +#if MEMMONITOR + PreviewProfiler.AddMainCopyTicks(System.Diagnostics.Stopwatch.GetTimestamp() - mainCopyStart); +#endif - if (_previewMonitor!=null && _previewMonitor.IsShown) { - _previewMonitor.UpdateImage(bmp2); + // 原因2: タイマー Tick に反映されなかった取りこぼしフレームを Dispose する + // _dispBufLock で ShowImage(コールバックスレッド)と MainRefreshTimer_Tick(UIスレッド)の + // 同時アクセスを防ぎ、二重 Dispose・使用中バッファの誤 Dispose を回避する + Bitmap toDispose = null; + lock (_dispBufLock) { + if (_isUpdateBuf && _dispBuf != null) { + toDispose = _dispBuf; // タイマー未反映の旧バッファを破棄予約 + } + _dispBuf = dispCopy; + _isUpdateBuf = true; } + toDispose?.Dispose(); // ロック外で GDI 解放(ロック保持時間を最小化) + // Invalidate() は呼ばない。毎フレーム呼ぶと Form1 の WM_PAINT が常時保留状態になり, + // 同一 UI スレッドを共有する副モニタの WM_TIMER(WM_PAINT より優先度が低い)が + // 飢餓状態になるため(本モニタも MainRefreshTimer_Tick による自ペース再描画に統一した)。 + + // 原因1: 副モニタが表示されているときだけフルコピーを生成する + // (非表示時に毎フレーム bmp2 を生成しリークしていた問題を修正) + bool feedSub = _previewMonitor != null && _previewMonitor.IsShown; +#if MEMMONITOR + // F9 トグル(ToggleSubMonitorProfiling)による計測用の一時抑制。 + // 抑制中は副モニタ用コピー生成も UpdateImage 呼び出しも走らせず, + // コールバックスレッドのコピー回数を本モニタ用の 1 回だけに戻す。 + if (_subMonitorSuppressed) feedSub = false; +#endif + if (feedSub) { +#if MEMMONITOR + PreviewProfiler.IsSubShown = true; + long subCopyStart = System.Diagnostics.Stopwatch.GetTimestamp(); + var subCopy = BitmapUtil.FastClone(bmp); + PreviewProfiler.AddSubCopyTicks(System.Diagnostics.Stopwatch.GetTimestamp() - subCopyStart); + _previewMonitor.UpdateImage(subCopy); +#else + _previewMonitor.UpdateImage(BitmapUtil.FastClone(bmp)); +#endif + } +#if MEMMONITOR + else { + PreviewProfiler.IsSubShown = false; + } +#endif } /// - /// プレビューウィンドウの再描画 + /// 本モニタ(picDisplay)の自ペース再描画(約30fps)。 + /// 新フレームがあるときだけ picDisplay.Image を差し替える(静止フレームは無駄に再描画しない)。 /// /// /// - private void Form1_Paint(object sender, PaintEventArgs e) { - if (!_isUpdateBuf || _dispBuf == null) return; + private void MainRefreshTimer_Tick(object sender, EventArgs e) { + // 原因2: _dispBufLock で ShowImage との競合を防ぎながらバッファを取り出す + // _dispBuf を null に戻すことで ShowImage 側が「表示中バッファ」を誤って Dispose しないようにする + Bitmap toDisplay; + lock (_dispBufLock) { + if (!_isUpdateBuf || _dispBuf == null) return; + toDisplay = _dispBuf; + _dispBuf = null; // 参照を手放し、次の ShowImage で二重 Dispose されないようにする + _isUpdateBuf = false; + } - picDisplay.Image?.Dispose(); - picDisplay.Image = _dispBuf; - _isUpdateBuf = false; + // picDisplay.Image の Dispose・差し替えは UI スレッド(このタイマー Tick)でのみ行う。 + picDisplay.Image?.Dispose(); // 直前に表示していたバッファを解放 + picDisplay.Image = toDisplay; +#if MEMMONITOR + PreviewProfiler.IncrementMainPaints(); +#endif } /// diff --git a/TIASshot/UI/MemoryMonitorForm.Designer.cs b/TIASshot/UI/MemoryMonitorForm.Designer.cs index 2920b24..2e64dda 100644 --- a/TIASshot/UI/MemoryMonitorForm.Designer.cs +++ b/TIASshot/UI/MemoryMonitorForm.Designer.cs @@ -18,6 +18,7 @@ this.chart = new System.Windows.Forms.DataVisualization.Charting.Chart(); this.lblCurrent = new System.Windows.Forms.Label(); + this.lblPreview = new System.Windows.Forms.Label(); this.lblMode = new System.Windows.Forms.Label(); this.btnStartStop = new System.Windows.Forms.Button(); this.btnReset = new System.Windows.Forms.Button(); @@ -81,6 +82,17 @@ this.lblCurrent.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; this.lblCurrent.Padding = new System.Windows.Forms.Padding(4, 0, 0, 0); + // lblPreview(プレビュー計測: コールバック/描画 FPS・コピー時間の切り分け用) + this.lblPreview.AutoSize = false; + this.lblPreview.Dock = System.Windows.Forms.DockStyle.Top; + this.lblPreview.Font = new System.Drawing.Font("Consolas", 9F, System.Drawing.FontStyle.Regular); + this.lblPreview.Height = 28; + this.lblPreview.Name = "lblPreview"; + this.lblPreview.TabIndex = 4; + this.lblPreview.Text = "プレビュー計測: ---"; + this.lblPreview.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + this.lblPreview.Padding = new System.Windows.Forms.Padding(4, 0, 0, 0); + // panelButtons this.btnStartStop.Name = "btnStartStop"; this.btnStartStop.Size = new System.Drawing.Size(100, 28); @@ -124,6 +136,7 @@ this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(640, 480); this.Controls.Add(this.chart); + this.Controls.Add(this.lblPreview); this.Controls.Add(this.lblCurrent); this.Controls.Add(this.lblMode); this.Controls.Add(this.panelButtons); @@ -137,6 +150,7 @@ private System.Windows.Forms.DataVisualization.Charting.Chart chart; private System.Windows.Forms.Label lblCurrent; + private System.Windows.Forms.Label lblPreview; private System.Windows.Forms.Label lblMode; private System.Windows.Forms.Button btnStartStop; private System.Windows.Forms.Button btnReset; diff --git a/TIASshot/UI/MemoryMonitorForm.cs b/TIASshot/UI/MemoryMonitorForm.cs index 39fb0bc..7e9fc51 100644 --- a/TIASshot/UI/MemoryMonitorForm.cs +++ b/TIASshot/UI/MemoryMonitorForm.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; using System.Text; using System.Windows.Forms; @@ -15,17 +16,41 @@ /// public partial class MemoryMonitorForm : Form { + // ---- GDI/USER ハンドル数取得(GDI ハンドル枯渇の疑いを確認するため)------ + [DllImport("user32.dll")] + private static extern uint GetGuiResources(IntPtr hProcess, uint uiFlags); + private const uint GR_GDIOBJECTS = 0; + private const uint GR_USEROBJECTS = 1; + // ---- サンプリング記録 ------------------------------------------------- private struct MemSample { public double ElapsedSec; public double PrivateMB; public double WorkingSetMB; + + // プレビュー計測(コピー負荷 vs UI 描画負荷の切り分け用。PreviewProfiler.Snapshot() の区間値) + public double CallbackFps; // ShowImage 実効フレームレート(コールバックスレッド律速の指標) + public double MainPaintFps; // MainRefreshTimer_Tick の実スワップレート + public double SubUpdateFps; // PreviewMonitor.UpdateImage 呼び出しレート + public double SubPaintFps; // PreviewMonitor_Paint の実スワップレート(副モニタ描画側の指標) + public double SubDropFps; // 副モニタ側の取りこぼしレート + public double SubPaintMs; // picPreview の実描画(base.OnPaint)平均所要時間 + public double MainCopyMs; // 本モニタ用コピー(dispCopy)の平均所要時間 + public double SubCopyMs; // 副モニタ用コピーの平均所要時間 + public uint GdiObjects; // プロセスの GDI オブジェクト数(GetGuiResources GR_GDIOBJECTS) + public uint UserObjects; // プロセスの USER オブジェクト数(GetGuiResources GR_USEROBJECTS) + public int HandleCount; // プロセスのハンドル数(Process.HandleCount) + public int ImgW; // 直近に表示したフレームの幅 + public int ImgH; // 直近に表示したフレームの高さ + public bool SubShown; // 区間内に副モニタが表示されていたか + public string Event; // "SUB_ON"/"SUB_OFF"/"CALIB_START"/"CALIB_END"/"SHOT_START"/"SHOT_END" を ";" 連結(無ければ "") } private readonly List _samples = new List(); private readonly System.Windows.Forms.Timer _timer; private readonly Stopwatch _stopwatch = new Stopwatch(); private bool _isRunning; + private double _lastTickElapsedSec = 0.0; // CSV 出力先 private readonly string _outputDir; @@ -35,6 +60,12 @@ public MemoryMonitorForm() { InitializeComponent(); + // F9 で副モニタ計測トグル(Form1.ToggleSubMonitorProfiling)を, + // このウィンドウにフォーカスがある場合にも呼び出せるようにする。 + // このフォームは ShowMemoryMonitor() で Show(this) されるため Owner は Form1。 + this.KeyPreview = true; + this.KeyDown += MemoryMonitorForm_KeyDown; + // モードラベルとウィンドウタイトルをビルド種別に応じて設定 #if LEAKY_REPRO lblMode.Text = "モード: リーク再現(修正前)"; @@ -63,6 +94,15 @@ this.Shown += (s, e) => StartSampling(); } + // ---- キー操作 -------------------------------------------------------- + + private void MemoryMonitorForm_KeyDown(object sender, KeyEventArgs e) { + if (e.KeyCode == Keys.F9) { + (this.Owner as Form1)?.ToggleSubMonitorProfiling(); + e.Handled = true; + } + } + // ---- サンプリング制御 ------------------------------------------------ private void StartSampling() { @@ -92,11 +132,57 @@ double privateMB = proc.PrivateMemorySize64 / (1024.0 * 1024.0); double wsMB = proc.WorkingSet64 / (1024.0 * 1024.0); + // GDI/USER ハンドル数・プロセスハンドル数(GDI ハンドル枯渇による描画劣化の疑いを確認する) + uint gdiObjects = GetGuiResources(proc.Handle, GR_GDIOBJECTS); + uint userObjects = GetGuiResources(proc.Handle, GR_USEROBJECTS); + int handleCount = proc.HandleCount; + + // プレビュー計測区間の実経過秒(Timer.Interval のブレを吸収するため実測差分を使う) + double intervalSec = elapsedSec - _lastTickElapsedSec; + if (intervalSec <= 0) intervalSec = _timer.Interval / 1000.0; + _lastTickElapsedSec = elapsedSec; + + // PreviewProfiler.Snapshot() は呼び出しごとにカウンタをゼロリセットして区間値を返す。 + // ここで各段の実効 FPS とコピー時間・描画時間平均を算出し, + // - 副モニタ ON で callback_fps が大きく落ちる → コピー/コールバックスレッド律速 + // - callback_fps は高いのに sub_paint_fps が sub_update_fps より大幅に低い → 副モニタの UI 描画律速 + // - CALIB_END/SHOT_END 直後に sub_paint_ms が跳ね上がる → 描画コスト増(副モニタ描画律速) + // 跳ね上がらず sub_paint_fps だけ落ちる → メッセージ枯渇/別要因 + // - gdi_objects/user_objects/handle_count が撮影のたびに増え続け sub_paint_ms 増加と相関 + // → GDI ハンドル枯渇による描画劣化の疑い + // - img_w/img_h が校正前後で変化 → プレビュー画像サイズ肥大による拡大負荷増の疑い + // を切り分けるための材料とする。 + var snap = PreviewProfiler.Snapshot(); + double callbackFps = snap.CallbackFrames / intervalSec; + double mainPaintFps = snap.MainPaints / intervalSec; + double subUpdateFps = snap.SubUpdates / intervalSec; + double subPaintFps = snap.SubPaints / intervalSec; + double subDropFps = snap.SubDrops / intervalSec; + + // 保留イベント(F9 トグル・校正・撮影)を消費する。 + // イベントは 500ms 区間内の直近サンプルに現れる(分析上この粒度で十分)。 + string eventLabel = PreviewProfiler.ConsumeEvents(); + // 記録 _samples.Add(new MemSample { ElapsedSec = elapsedSec, PrivateMB = privateMB, WorkingSetMB = wsMB, + CallbackFps = callbackFps, + MainPaintFps = mainPaintFps, + SubUpdateFps = subUpdateFps, + SubPaintFps = subPaintFps, + SubDropFps = subDropFps, + SubPaintMs = snap.SubPaintMs, + MainCopyMs = snap.MainCopyMs, + SubCopyMs = snap.SubCopyMs, + GdiObjects = gdiObjects, + UserObjects = userObjects, + HandleCount = handleCount, + ImgW = snap.ImgW, + ImgH = snap.ImgH, + SubShown = snap.SubShown, + Event = eventLabel, }); // グラフ追加 @@ -113,6 +199,13 @@ int n = (int)Math.Round(elapsedSec); lblCurrent.Text = $"現在: Private {privateGB:F2} GB / WS {wsGB:F2} GB(経過 {n}s)"; + + string eventSuffix = string.IsNullOrEmpty(eventLabel) ? "" : $" / event {eventLabel}"; + lblPreview.Text = + $"CB {callbackFps:F1} / mainPaint {mainPaintFps:F1} / subUpd {subUpdateFps:F1} subPaint {subPaintFps:F1} subDrop {subDropFps:F1} / " + + $"copy main {snap.MainCopyMs:F1}ms sub {snap.SubCopyMs:F1}ms / subPaint {snap.SubPaintMs:F1}ms / " + + $"gdi {gdiObjects} user {userObjects} handle {handleCount} / img {snap.ImgW}x{snap.ImgH} / " + + $"sub {(snap.SubShown ? "ON" : "OFF")}{eventSuffix} (F9: 副モニタ計測トグル)"; } } @@ -130,9 +223,13 @@ StopSampling(); _samples.Clear(); _stopwatch.Reset(); + _lastTickElapsedSec = 0.0; + PreviewProfiler.Snapshot(); // 未読の累積値を捨てて次回計測をゼロから開始する + PreviewProfiler.ConsumeEvents(); // 未読のイベントも破棄する chart.Series["Private"].Points.Clear(); chart.Series["WorkingSet"].Points.Clear(); lblCurrent.Text = "現在: --- (リセット)"; + lblPreview.Text = "プレビュー計測: ---"; } private void btnSaveCsv_Click(object sender, EventArgs e) { @@ -144,9 +241,16 @@ string path = NextCsvPath(); var sb = new StringBuilder(); - sb.AppendLine("elapsed_sec,private_MB,ws_MB"); + sb.AppendLine( + "elapsed_sec,private_MB,ws_MB,callback_fps,main_paint_fps,sub_update_fps,sub_paint_fps,sub_drop_fps," + + "sub_paint_ms,main_copy_ms,sub_copy_ms,gdi_objects,user_objects,handle_count,img_w,img_h,sub_shown,event"); foreach (var s in _samples) { - sb.AppendLine($"{s.ElapsedSec:F3},{s.PrivateMB:F2},{s.WorkingSetMB:F2}"); + sb.AppendLine( + $"{s.ElapsedSec:F3},{s.PrivateMB:F2},{s.WorkingSetMB:F2}," + + $"{s.CallbackFps:F2},{s.MainPaintFps:F2},{s.SubUpdateFps:F2},{s.SubPaintFps:F2},{s.SubDropFps:F2}," + + $"{s.SubPaintMs:F3},{s.MainCopyMs:F3},{s.SubCopyMs:F3}," + + $"{s.GdiObjects},{s.UserObjects},{s.HandleCount},{s.ImgW},{s.ImgH}," + + $"{(s.SubShown ? 1 : 0)},{s.Event}"); } File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); diff --git a/TIASshot/UI/PreviewMonitor.Designer.cs b/TIASshot/UI/PreviewMonitor.Designer.cs index 34280e8..d9b390e 100644 --- a/TIASshot/UI/PreviewMonitor.Designer.cs +++ b/TIASshot/UI/PreviewMonitor.Designer.cs @@ -23,7 +23,12 @@ /// the contents of this method with the code editor. /// private void InitializeComponent() { +#if MEMMONITOR + // 副モニタの実描画時間計測用(sub_paint_ms)。PictureBox 派生で挙動は同一。 + this.picPreview = new TIASshot.TimedPictureBox(); +#else this.picPreview = new System.Windows.Forms.PictureBox(); +#endif this.label1 = new System.Windows.Forms.Label(); this.pictureBox1 = new System.Windows.Forms.PictureBox(); this.label2 = new System.Windows.Forms.Label(); @@ -125,7 +130,6 @@ this.ShowInTaskbar = false; this.Text = "PreviewMonitor"; this.Load += new System.EventHandler(this.PreviewMonitor_Load); - this.Paint += new System.Windows.Forms.PaintEventHandler(this.PreviewMonitor_Paint); ((System.ComponentModel.ISupportInitialize)(this.picPreview)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit(); this.ResumeLayout(false); diff --git a/TIASshot/UI/PreviewMonitor.cs b/TIASshot/UI/PreviewMonitor.cs index 2abdcff..6acac3f 100644 --- a/TIASshot/UI/PreviewMonitor.cs +++ b/TIASshot/UI/PreviewMonitor.cs @@ -15,6 +15,15 @@ private Size _size = new Size(0, 0); private Bitmap _dispBuf; private bool _isUpdateBuf = false; + private readonly object _dispBufLock = new object(); + + // 副モニタ(セカンダリ・非アクティブ・ボーダーレスウィンドウ)は,コールバックスレッドから + // 毎フレーム Invalidate() すると WM_PAINT が飢餓状態になり sub_paint_fps が数fpsまで落ち込む + // (実描画コスト・GDI 枯渇・画像サイズ肥大は計測により否定済み)。 + // そのため UI タイマーで一定周期(約30fps)に自ペース再描画し,コールバックスレッドからは + // 「最新フレームの保持」のみを行い Invalidate は一切呼ばない。 + private const int RefreshIntervalMs = 33; // ≒30fps + private readonly System.Windows.Forms.Timer _refreshTimer; public bool IsShown => (_size.Width > 0); @@ -32,6 +41,12 @@ _size = scr.Bounds.Size; } } + + _refreshTimer = new System.Windows.Forms.Timer(); + _refreshTimer.Interval = RefreshIntervalMs; + _refreshTimer.Tick += RefreshTimer_Tick; + + this.FormClosing += (s, e) => _refreshTimer.Stop(); } /// @@ -43,28 +58,62 @@ // プレビューウィンドウの位置とサイズを設定 Location = _location; Size = _size; + + if (IsShown) _refreshTimer.Start(); } /// - /// プレビューウィンドウの表示状態を更新 + /// プレビューウィンドウの表示状態を更新(カメラのコールバックスレッドから毎フレーム呼ばれる) + /// 最新フレームの保持のみを行い,UI 再描画のトリガー(Invalidate)は呼ばない。 + /// 実際の表示更新は RefreshTimer_Tick(UI スレッド)が自ペースで行う。 /// /// public void UpdateImage(Bitmap image) { - _dispBuf = image; - _isUpdateBuf = true; - Invalidate(); +#if MEMMONITOR + PreviewProfiler.IncrementSubUpdates(); +#endif + // 原因3: RefreshTimer_Tick に反映されなかった旧バッファを上書き前に Dispose する + // _dispBufLock で UpdateImage(コールバックスレッド)と RefreshTimer_Tick(UIスレッド)の + // 競合・二重 Dispose を防ぐ + Bitmap toDispose = null; + lock (_dispBufLock) { + if (_isUpdateBuf && _dispBuf != null) { + toDispose = _dispBuf; + } + _dispBuf = image; + _isUpdateBuf = true; + } +#if MEMMONITOR + if (toDispose != null) PreviewProfiler.IncrementSubDrops(); +#endif + toDispose?.Dispose(); // ロック外で GDI 解放(コールバックスレッド上だが,まだ表示に使われていないフレームのみ) + // Invalidate() は呼ばない(毎フレーム呼ぶと WM_PAINT が飢餓状態になるため)。 } /// - /// プレビューウィンドウの再描画イベント + /// UI タイマーによる自ペース再描画(約30fps)。 + /// 新フレームがあるときだけ picPreview.Image を差し替える(静止フレームは無駄に再描画しない)。 /// - /// - private void PreviewMonitor_Paint(object sender, PaintEventArgs e) { - if (!IsShown || !_isUpdateBuf || _dispBuf == null) return; + private void RefreshTimer_Tick(object sender, EventArgs e) { + if (!IsShown) return; - picPreview.Image?.Dispose(); - picPreview.Image = _dispBuf; - _isUpdateBuf = false; + // 原因3: _dispBufLock で UpdateImage との競合を防ぎながらバッファを取り出す + Bitmap toDisplay; + lock (_dispBufLock) { + if (!_isUpdateBuf || _dispBuf == null) return; + toDisplay = _dispBuf; + _dispBuf = null; // 参照を手放し、UpdateImage 側が誤 Dispose しないようにする + _isUpdateBuf = false; + } + + // picPreview.Image の Dispose・差し替えは UI スレッド(このタイマー Tick)でのみ行う。 + // 代入によって picPreview 自身が再描画され,MEMMONITOR の TimedPictureBox.OnPaint 計測も + // 引き続き機能する。 + picPreview.Image?.Dispose(); // 直前に表示していたバッファを解放 + picPreview.Image = toDisplay; +#if MEMMONITOR + PreviewProfiler.IncrementSubPaints(); +#endif } } } diff --git a/TIASshot/UI/PreviewProfiler.cs b/TIASshot/UI/PreviewProfiler.cs new file mode 100644 index 0000000..4ad43d8 --- /dev/null +++ b/TIASshot/UI/PreviewProfiler.cs @@ -0,0 +1,162 @@ +#if MEMMONITOR +using System.Diagnostics; +using System.Text; +using System.Threading; + +namespace TIASshot { + + /// + /// プレビュー表示経路の性能計測(MEMMONITOR 限定)。 + /// カメラのコールバックスレッド(Form1.ShowImage)と UI スレッド(MainRefreshTimer_Tick 等)の + /// 双方から呼ばれるため,すべて Interlocked/lock で加算するスレッド安全なカウンタとする。 + /// 「コピー負荷(コールバックスレッド)」「副モニタの実描画コスト」「GDI/USER ハンドル枯渇」 + /// 「プレビュー画像サイズ肥大」のどれが副モニタ描画崩壊の原因かを切り分けるために, + /// 各段の呼び出し回数・所要時間・イベント(校正/撮影/F9 トグル)を計測する。 + /// + internal static class PreviewProfiler { + + // ---- カウンタ(区間内の呼び出し回数)---------------------------------- + private static long _callbackFrames; // ShowImage 呼び出し数(コールバックスレッドの実効フレーム数) + private static long _mainPaints; // MainRefreshTimer_Tick で実際に picDisplay.Image を差し替えた回数 + private static long _subUpdates; // PreviewMonitor.UpdateImage 呼び出し数 + private static long _subPaints; // RefreshTimer_Tick で実際に picPreview.Image を差し替えた回数 + private static long _subDrops; // UpdateImage で旧バッファを取りこぼし Dispose した回数 + + // ---- コピー時間累積(Stopwatch tick)---------------------------------- + private static long _mainCopyTicks; + private static long _mainCopySamples; + private static long _subCopyTicks; + private static long _subCopySamples; + + // ---- 副モニタの実描画時間累積(Stopwatch tick)-------------------------- + // TimedPictureBox.OnPaint(picPreview の実際の GDI 描画: base.OnPaint)の所要時間。 + // 「描画コストそのものが増えているか」と「メッセージ枯渇で呼び出し自体が減っているか」を + // sub_paint_fps(呼び出しレート)と組み合わせて切り分けるための指標。 + private static long _subPaintTicks; + private static long _subPaintSamples; + + // ---- 直近の副モニタ表示状態 -------------------------------------------- + private static int _isSubShown; // 0/1 を Interlocked で扱う(bool は Interlocked 非対応のため) + + public static bool IsSubShown { + get { return Interlocked.CompareExchange(ref _isSubShown, 0, 0) != 0; } + set { Interlocked.Exchange(ref _isSubShown, value ? 1 : 0); } + } + + // ---- 直近の表示画像サイズ(プレビュー画像肥大の確認用)-------------------- + private static int _lastImgW; + private static int _lastImgH; + + /// Form1.ShowImage から呼ばれ,直近に表示したフレームのサイズを記録する + public static void SetLastImageSize(int w, int h) { + Interlocked.Exchange(ref _lastImgW, w); + Interlocked.Exchange(ref _lastImgH, h); + } + + // ---- イベント(CSV の event 列用)---------------------------------------- + // F9 トグル(SUB_ON/SUB_OFF)・校正(CALIB_START/CALIB_END)・撮影(SHOT_START/SHOT_END)を + // 文字列イベントとして記録する。同一 500ms 区間内に複数発生した場合は ";" で連結する。 + // 呼び出し元(コールバックスレッド・UI スレッド・撮影スレッド)が異なるため lock で保護する。 + private static readonly object _eventLock = new object(); + private static readonly StringBuilder _pendingEvents = new StringBuilder(); + + /// イベントタグを保留イベントに追加する(スレッドセーフ) + public static void MarkEvent(string tag) { + lock (_eventLock) { + if (_pendingEvents.Length > 0) _pendingEvents.Append(';'); + _pendingEvents.Append(tag); + } + } + + /// 保留中のイベント文字列を読み取ってクリアする(無ければ "") + public static string ConsumeEvents() { + lock (_eventLock) { + string result = _pendingEvents.ToString(); + _pendingEvents.Clear(); + return result; + } + } + + // ---- カウンタ加算ヘルパ ------------------------------------------------- + public static void IncrementCallbackFrames() => Interlocked.Increment(ref _callbackFrames); + public static void IncrementMainPaints() => Interlocked.Increment(ref _mainPaints); + public static void IncrementSubUpdates() => Interlocked.Increment(ref _subUpdates); + public static void IncrementSubPaints() => Interlocked.Increment(ref _subPaints); + public static void IncrementSubDrops() => Interlocked.Increment(ref _subDrops); + + /// 本モニタ用コピー(dispCopy 生成)の所要時間を記録する + public static void AddMainCopyTicks(long ticks) { + Interlocked.Add(ref _mainCopyTicks, ticks); + Interlocked.Increment(ref _mainCopySamples); + } + + /// 副モニタ用コピー(new Bitmap(bmp))の所要時間を記録する + public static void AddSubCopyTicks(long ticks) { + Interlocked.Add(ref _subCopyTicks, ticks); + Interlocked.Increment(ref _subCopySamples); + } + + /// TimedPictureBox(picPreview)の実描画(base.OnPaint)所要時間を記録する + public static void AddSubPaintTicks(long ticks) { + Interlocked.Add(ref _subPaintTicks, ticks); + Interlocked.Increment(ref _subPaintSamples); + } + + // ---- スナップショット ---------------------------------------------------- + + /// + /// 区間計測結果のスナップショット。呼び出し回数はそのまま(呼び出し側で区間秒で割って FPS 換算する), + /// コピー時間・描画時間は ms 平均に変換済み。 + /// + public struct ProfilerSnapshot { + public long CallbackFrames; + public long MainPaints; + public long SubUpdates; + public long SubPaints; + public long SubDrops; + public double MainCopyMs; // 平均コピー時間(ms)。サンプルなしなら 0 + public double SubCopyMs; // 平均コピー時間(ms)。サンプルなしなら 0 + public double SubPaintMs; // picPreview の平均実描画時間(ms)。サンプルなしなら 0 + public bool SubShown; + public int ImgW; // 直近に表示したフレームの幅 + public int ImgH; // 直近に表示したフレームの高さ + } + + /// + /// 現在値を読み取ってゼロにリセットし,区間スナップショットを返す。 + /// MemoryMonitorForm の Timer_Tick(500ms 間隔)から呼ばれる想定。 + /// + public static ProfilerSnapshot Snapshot() { + long callbackFrames = Interlocked.Exchange(ref _callbackFrames, 0); + long mainPaints = Interlocked.Exchange(ref _mainPaints, 0); + long subUpdates = Interlocked.Exchange(ref _subUpdates, 0); + long subPaints = Interlocked.Exchange(ref _subPaints, 0); + long subDrops = Interlocked.Exchange(ref _subDrops, 0); + + long mainCopyTicks = Interlocked.Exchange(ref _mainCopyTicks, 0); + long mainCopySamples = Interlocked.Exchange(ref _mainCopySamples, 0); + long subCopyTicks = Interlocked.Exchange(ref _subCopyTicks, 0); + long subCopySamples = Interlocked.Exchange(ref _subCopySamples, 0); + + long subPaintTicks = Interlocked.Exchange(ref _subPaintTicks, 0); + long subPaintSamples = Interlocked.Exchange(ref _subPaintSamples, 0); + + double tickToMs = 1000.0 / Stopwatch.Frequency; + + return new ProfilerSnapshot { + CallbackFrames = callbackFrames, + MainPaints = mainPaints, + SubUpdates = subUpdates, + SubPaints = subPaints, + SubDrops = subDrops, + MainCopyMs = mainCopySamples > 0 ? (mainCopyTicks * tickToMs) / mainCopySamples : 0.0, + SubCopyMs = subCopySamples > 0 ? (subCopyTicks * tickToMs) / subCopySamples : 0.0, + SubPaintMs = subPaintSamples > 0 ? (subPaintTicks * tickToMs) / subPaintSamples : 0.0, + SubShown = IsSubShown, + ImgW = Interlocked.CompareExchange(ref _lastImgW, 0, 0), + ImgH = Interlocked.CompareExchange(ref _lastImgH, 0, 0), + }; + } + } +} +#endif diff --git a/TIASshot/UI/TimedPictureBox.cs b/TIASshot/UI/TimedPictureBox.cs new file mode 100644 index 0000000..7e05bd5 --- /dev/null +++ b/TIASshot/UI/TimedPictureBox.cs @@ -0,0 +1,21 @@ +#if MEMMONITOR +using System.Diagnostics; +using System.Windows.Forms; + +namespace TIASshot { + + /// + /// 副モニタの実描画時間を計測する PictureBox(MEMMONITOR 限定)。 + /// base.OnPaint(実際の GDI 描画)の所要時間を PreviewProfiler に記録し, + /// 「副モニタの描画コストそのものが増えているか」を sub_paint_ms として可視化する。 + /// 本番ビルドでは本クラスごとコンパイルされないため,通常の PictureBox と挙動は一切変わらない。 + /// + internal class TimedPictureBox : PictureBox { + protected override void OnPaint(PaintEventArgs pe) { + long t0 = Stopwatch.GetTimestamp(); + base.OnPaint(pe); + PreviewProfiler.AddSubPaintTicks(Stopwatch.GetTimestamp() - t0); + } + } +} +#endif diff --git a/TIASshot/Utils/BitmapUtil.cs b/TIASshot/Utils/BitmapUtil.cs new file mode 100644 index 0000000..82d10b6 --- /dev/null +++ b/TIASshot/Utils/BitmapUtil.cs @@ -0,0 +1,70 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; + +namespace TIASshot { + + /// + /// Bitmap 操作の共有ユーティリティ。 + /// プレビュー表示経路で毎フレーム発生する Bitmap の複製処理を高速化するために, + /// GDI+ の DrawImage 経路()ではなく, + /// LockBits による生画素の直接コピー(memcpy 相当)を提供する。 + /// + internal static class BitmapUtil { + + [DllImport("kernel32.dll", EntryPoint = "RtlMoveMemory")] + private static extern void CopyMemory(IntPtr dest, IntPtr src, UIntPtr count); + + /// + /// Bitmap を高速に複製する。 + /// 入力と同じ PixelFormat・サイズの新規 Bitmap を確保し,LockBits で取得した + /// 生画素バッファをネイティブ memcpy(RtlMoveMemory)で丸ごとコピーする。 + /// 生成される Bitmap は入力とは独立した画素バッファを持つため, + /// 呼び出し元スレッドとは別のスレッド(UI スレッド等)で安全に保持・Dispose できる + /// ( によるフルコピーと同じ所有権モデル)。 + /// + /// 複製元の Bitmap(読み取りのみ・変更しない) + /// src と独立した画素バッファを持つ新規 Bitmap + public static Bitmap FastClone(Bitmap src) { + if (src == null) return null; + + // インデックスカラー系(8bpp 以下)はパレットを保持する必要があり, + // LockBits の単純コピーだけではパレット情報が失われるため, + // 安全網として従来どおりの GDI+ フルコピーにフォールバックする。 + // ※ この経路は入力と独立・色が等価なコピーを返すが,GDI+ の Bitmap(Image) + // により PixelFormat は 32bppArgb に変換される(8bppIndexed のままにはならない)。 + // プレビュー画像(Mat.ToBitmap() 由来の 24bppRgb)は通常この分岐に入らない。 + if ((src.PixelFormat & PixelFormat.Indexed) != 0) { + return new Bitmap(src); + } + + var rect = new Rectangle(0, 0, src.Width, src.Height); + var dst = new Bitmap(src.Width, src.Height, src.PixelFormat); + + BitmapData srcData = null; + BitmapData dstData = null; + try { + srcData = src.LockBits(rect, ImageLockMode.ReadOnly, src.PixelFormat); + dstData = dst.LockBits(rect, ImageLockMode.WriteOnly, dst.PixelFormat); + + // ストライドは通常一致するが,念のため行ごとに小さい方のバイト数だけコピーする + // (負ストライド/パディング差があっても src・dst それぞれの範囲を超えない堅牢な実装) + int bytesPerRow = Math.Min(Math.Abs(srcData.Stride), Math.Abs(dstData.Stride)); + var count = (UIntPtr)bytesPerRow; + + for (int y = 0; y < src.Height; y++) { + IntPtr srcRow = IntPtr.Add(srcData.Scan0, y * srcData.Stride); + IntPtr dstRow = IntPtr.Add(dstData.Scan0, y * dstData.Stride); + CopyMemory(dstRow, srcRow, count); + } + } finally { + // src は ReadOnly ロックのみなので UnlockBits は元データを変更しない(src は破損しない) + if (srcData != null) src.UnlockBits(srcData); + if (dstData != null) dst.UnlockBits(dstData); + } + + return dst; + } + } +} diff --git "a/docs/03_PLAN/PLAN_03_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\343\203\241\343\203\242\343\203\252\343\203\252\343\203\274\343\202\257\344\277\256\346\255\243\350\250\210\347\224\273.md" "b/docs/03_PLAN/PLAN_03_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\343\203\241\343\203\242\343\203\252\343\203\252\343\203\274\343\202\257\344\277\256\346\255\243\350\250\210\347\224\273.md" new file mode 100644 index 0000000..155b4a3 --- /dev/null +++ "b/docs/03_PLAN/PLAN_03_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\343\203\241\343\203\242\343\203\252\343\203\252\343\203\274\343\202\257\344\277\256\346\255\243\350\250\210\347\224\273.md" @@ -0,0 +1,85 @@ +# プレビューメモリリーク修正計画 (Preview Memory Leak Fix Plan) + +## 前提 (Prerequisites) + +- 本計画は,`PLAN_02_バグ修正計画.md`(連続撮影クラッシュ=撮影経路の修正)とは**独立した別系統のバグ**を扱う.PLAN_02 は「撮影時」の不具合,本計画は「プレビュー表示中(撮影していなくても)長時間起動で落ちる」不具合を対象とする. +- 修正は1件ずつ独立して行い,都度ビルド・動作確認する. +- コミットメッセージは `[fix]` タグを使用する. +- 該当箇所はリファクタ後の現構造(`Cameras/`・`UI/`)で記載する.プレビュー供給は `Cameras/IScam.cs`・`Cameras/Lucam.cs`,表示は `UI/Form1.cs`・`UI/PreviewMonitor.cs` に分かれている. + +## 症状 (Symptom) + +- ver1.6 で,アプリを**長時間起動していると落ちる**ことがあった. +- 連続撮影の有無に関わらず発生する(撮影せずプレビュー表示のまま放置していても起こりうる). +- ver1.6(タグ `v1.6`)と現行コードの該当経路を比較した結果,**プレビュー表示経路は v1.6 からほぼ同一で未修正**であり,現行でも同じクラッシュが起こりうる. + +## 原因 (Cause) + +プレビューはカメラのフレームレート(30〜60 fps 相当)で連続的にフレームを供給し続ける.`IScam` では `Cameras/IScam.cs` の `Retrieve`(`FrameQueueSink` コールバック)が毎フレーム `_form.ShowImage(imgt.ToBitmap())` を呼ぶ.この経路で `Bitmap` が確実に解放されず,時間とともに蓄積する. + +`Bitmap` は内部に GDI ハンドル(HBITMAP)とネイティブ画素バッファ(IScam の 1840×2440×3 で約 13 MB/枚)を持つ.一方マネージドのラッパは小さいため GC がメモリ圧を感じにくく,回収が遅れる.結果として **GDI ハンドルの枯渇(既定上限はプロセスあたり 1 万)** か **ワーキングセット肥大による OutOfMemory/GDI+ のエラー**で,時間とともにクラッシュする.確率的に「たまに/いずれ」落ちる症状と整合する. + +### 原因1: `ShowImage` のフルコピー Bitmap が毎フレームリークする(決定的・最優先) + +- `UI/Form1.cs` の `ShowImage(Bitmap bmp)` は冒頭で `var bmp2 = new Bitmap(bmp);` と**フルコピー Bitmap を毎フレーム生成**する. +- `bmp2` は `_previewMonitor.IsShown` が true のとき(副プレビューモニタ表示時)だけ `PreviewMonitor.UpdateImage` に渡される.**通常運用(副モニタ非表示)では `bmp2` はどこにも使われず,かつ Dispose されない**ため,1 フレームごとに 1 枚まるごとリークする. +- 撮影せずプレビュー表示しているだけでも蓄積する=「長時間アイドルで落ちる」の主因. + +**該当箇所** + +- `UI/Form1.cs` の `ShowImage`(`bmp2 = new Bitmap(bmp)` と,`IsShown` 分岐). + +### 原因2: 表示バッファの取りこぼしフレームが Dispose されない + +- `ShowImage` は `_dispBuf = bmp;` で表示バッファ参照を差し替え,`Invalidate()` で再描画を要求する.実際の Dispose は `Form1_Paint` の `picDisplay.Image?.Dispose();` で行われる. +- `Invalidate()` による `WM_PAINT` は合体(コアレス)されるため,`Form1_Paint` が走る前に次フレームが届くと `_dispBuf` が複数回上書きされる.`IScam` 経路は二重バッファを持たないため,**Paint に到達しなかった `ToBitmap()` 結果は Dispose を通らずに捨てられ,リークする**. + +**該当箇所** + +- `UI/Form1.cs` の `ShowImage`(`_dispBuf` 差し替え)/`Form1_Paint`(`picDisplay.Image?.Dispose()`). +- `Cameras/IScam.cs` の `Retrieve`(毎フレーム `ToBitmap()` を `ShowImage` へ渡す.二重バッファなし). +- ※ `Cameras/Lucam.cs` の `PreviewCallback` は `_bmps[2]` の二重バッファで `ToBitmap()` 結果を 2 枚に抑えて Dispose しており,この点は安全.ただし原因1(`ShowImage` 内の `bmp2`)は Lucam 経路でも同様にリークする. + +### 原因3: `PreviewMonitor.UpdateImage` の上書き前バッファが Dispose されない + +- `UI/PreviewMonitor.cs` の `UpdateImage` も単一バッファを上書きする構造で,差し替え前の旧バッファを Dispose していない(副プレビューモニタ表示時のみ顕在化). + +**該当箇所** + +- `UI/PreviewMonitor.cs` の `UpdateImage`(表示バッファ上書き). + +## 修正方針 (Fix Policy) + +### 優先度1: `ShowImage` のフルコピー Bitmap を必要時のみ生成する + +- `bmp2 = new Bitmap(bmp)` を**無条件生成から,`_previewMonitor != null && _previewMonitor.IsShown` が true のときだけ生成**に変更する.未表示時のフルコピーリークを止める(効果が最も大きく,変更も小さい). + +### 優先度2: プレビュー表示バッファを確実に Dispose する + +- 取りこぼしフレームを含め,プレビュー用 `Bitmap` を確実に解放する.方針候補: + - `Lucam` と同様の**二重バッファ+確実な Dispose** に表示経路を統一する,または + - `_dispBuf` を差し替える前に,まだ `picDisplay.Image` に反映されていない旧 `_dispBuf` を Dispose する(Paint を通らなかったフレームも必ず破棄する). +- `picDisplay.Image` への代入は「直前の Image を Dispose してから差し替え」を保証する. + +### 優先度3: `PreviewMonitor.UpdateImage` の上書き前 Dispose + +- `UpdateImage` で表示バッファを上書きする前に,旧バッファを Dispose する. + +## 検証 (Verification) + +- `MEMMONITOR` ビルド(`UI/MemoryMonitorForm`)で,**撮影せずプレビュー表示のまま放置**し,メモリ(PrivateMemory/WorkingSet)が時間とともに増え続けないことを確認する.修正前後で対比する. +- 既存のメモリ計測ベンチ(`PLAN_02` の「効果検証」・[TEST_01](../06_TEST/TEST_01_メモリ修正効果計測.md))は**撮影フロー対象**であり,本件(プレビュー放置)はカバーしていない.プレビュー放置シナリオの観測は別途行う. +- GDI ハンドルのリークが疑われるため,観測ウィンドウに **GDI オブジェクト数**(`GetGuiResources` 等)の表示を追加して,メモリだけでなくハンドル数も時間で増えないことを確認することを推奨する(任意). + +## 修正順序まとめ (Summary) + +| 順位 | 原因 | 該当ファイル | クラッシュとの関連 | +| --- | --- | --- | --- | +| 1 | `ShowImage` のフルコピー Bitmap が毎フレームリーク | `UI/Form1.cs` | 直接原因(アイドル時の主因・決定的リーク) | +| 2 | 表示バッファの取りこぼしフレーム未 Dispose | `UI/Form1.cs` / `Cameras/IScam.cs` | 直接原因(二重バッファなしで取りこぼし) | +| 3 | `PreviewMonitor.UpdateImage` の上書き前未 Dispose | `UI/PreviewMonitor.cs` | 副モニタ表示時のリーク | + +## 関連 (Related) + +- [PLAN_02 バグ修正計画](PLAN_02_バグ修正計画.md) — 連続撮影クラッシュ(撮影経路).本計画とは別系統. +- [TEST_01 メモリ修正効果計測](../06_TEST/TEST_01_メモリ修正効果計測.md) — 撮影経路のメモリ計測結果. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a13e6ab..e37af13 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -76,3 +76,17 @@ - 設計判断: 本番 `CameraBase.RunShotLoop`/`SaveThread`/`SaveImages` を直接ヘッドレス実行するには Form1・Config・TCC・校正で埋まる `_convRGB2SRGB` 依存が重すぎる → 本番コード無改造のまま「フローの構造再現」を選択(本番メソッドそのものの呼び出しではない旨を明記)。リーク再現と計測ユーティリティは `MemBenchSupport` に共有化し既存ベンチと統一。 - 結果の要点: フロー再現フルスケールで修正後増分は修正前の約 34%(per-frame 112 vs 326 MB)。単体隔離(約 17〜20%)と数値が違うのは、フロー版がフレーム保持+多チャンネル+並行処理の一時メモリを含むため。 - 役割分担の整理: メモリの「数値」はベンチ(Claude 側・再現性高)で、実使用の「動画デモ」は MEMMONITOR +実機録画(人間)で、と用途を分けた。 + +- [x] **PLAN_03 プレビューメモリリーク修正+派生クラッシュ・副モニタ描画飢餓の解決** (2026-07-11): PLAN_03(撮影経路とは別系統の「プレビュー表示中の長時間クラッシュ」)の原因1〜3(`ShowImage` のフルコピー Bitmap リーク・表示バッファの取りこぼし未 Dispose・`PreviewMonitor.UpdateImage` の上書き前未 Dispose)を修正。実機検証の過程でさらに 2 件(Lucam のクロススレッド GDI クラッシュ・副モニタの描画飢餓)が発覚し、あわせて解決した。実機のメモリ観測(MEMMONITOR)で反復検証。 + - 動機: ver1.6 から続く「プレビュー表示のまま放置で落ちる」不具合。GDI ハンドル/ネイティブ画素バッファの蓄積が疑われた。 + - 設計判断(表示バッファの所有権統一): 表示用 Bitmap は必ず「カメラがコールバックスレッドで Dispose するバッファ」とは別の UI 側所有コピーにする。Lucam は二重バッファ `_bmps[]` をコールバックスレッドで Dispose するため、それを直接 `picDisplay.Image` に載せると UI の OnPaint と衝突し「オブジェクトは現在他の場所で使用されています」で落ちる(v1.6 からの潜在バグ)。→ 入力 bmp は「読むだけ」に統一し、表示は UI 所有コピー、入力の Dispose 責任は呼び出し側(IScam は自分の ToBitmap を破棄)に置く。BeginInvoke 案は Lucam の「ShowImage 直後に _bmps を Dispose」する同期前提を壊すため不採用。 + - 失敗パターンの圧縮(副モニタ描画飢餓): ① 描画コスト増/GDI ハンドル枯渇/画像サイズ肥大を疑う → MEMMONITOR にプレビュー計測(各段 FPS・コピー時間・実描画時間・GDI/USER/handle 数・画像サイズ・校正/撮影/F9 イベントを CSV 化)を追加して**全て計測で否定** → ② 真因は「セカンダリ(非アクティブ)ウィンドウの WM_PAINT 飢餓」。校正完了で `DetectChart` が止まりコールバックが高速化 → コールバックスレッドからの毎フレーム `Invalidate()` 洪水で Form1 が常時 WM_PAINT 保留になり、同一 UI スレッドを共有する副モニタが飢餓(`sub_paint_fps`≈2)。 + - 失敗パターンの圧縮(対策): ① 副モニタのみタイマー駆動化 → **WM_TIMER < WM_PAINT の優先度**により Form1 の Invalidate 洪水が副モニタのタイマーを飢餓させ、ほぼ無効 → ② 本モニタ含め**コールバックスレッドからの毎フレーム Invalidate を全廃**し、両プレビューを自ペースの UI タイマー(≒30fps)駆動に統一。洪水源が消え副モニタが steady ~10fps に回復(本モニタと公平に共有)。 + - 検証の要点: `sub_paint_ms`(実描画時間)が崩壊中も低いまま=描画は軽い、`img_w/h` 一定=画像肥大でない、`gdi_objects` が上限(1万)に対し桁違いに小さい=ハンドル枯渇でない、という三点の否定でスケジューリング(メッセージ枯渇)に原因を絞り込めた。 + - 計測スカフォールドは MEMMONITOR 限定(Release 未含有)でそのまま残置(今後の検証・GDI 観測用。PLAN_03 も GDI 数観測を推奨)。 + - 後段: 両プレビューの上限 ~10fps は毎フレームのフルフレーム Bitmap コピー(各 ~25ms)が律速だったため、別コミットで高速化(下記)。Phase 2/3(自動テスト・リファクタ)は本変更が WinForms UI スレッド・GDI ライフサイクル・カメラコールバック並行性主体で既存ユニットテスト基盤の対象外のため、実機メモリ計測での検証に代替(ユーザー指示によりこの検証済み状態でコミット)。 + +- [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+ フルコピーにフォールバック(この経路は色は等価だが PixelFormat が 32bppArgb に変換される点に注意)。 + - テスト方針(本 PLAN_03 一連の実装共通): 自動 NUnit テストで担保できるのは純ロジックの `FastClone` のみ(独立コピー・src 非破壊・ストライドパディング・Indexed フォールバック・null)。プレビューのタイマー駆動描画・`_dispBufLock` を介したクロススレッド Dispose の所有権・WM_PAINT/WM_TIMER のスケジューリングは UI スレッド/GDI/実カメラ・副ディスプレイ結合層でユニットテスト不能のため、実機 MEMMONITOR 計測(コピー時間・各段 FPS・GDI/handle・描画時間の CSV)で反復検証した。