diff --git a/CLAUDE.md b/CLAUDE.md index 76e736e..bcd7a6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_03 プレビューメモリリーク(原因1〜3)を修正.派生で発覚した Lucam のクロススレッド GDI クラッシュ・副モニタ描画飢餓も修正し,両プレビューをタイマー駆動化(MEMMONITOR にプレビュー計測を追加) +最新: PLAN_03 プレビューメモリリーク(原因1〜3)+派生の Lucam クロススレッド GDI クラッシュ・副モニタ描画飢餓を修正し両プレビューをタイマー駆動化.さらにプレビューのフレームコピーを LockBits で高速化(~25ms→~2.5ms,実効 fps 約2倍) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index cf4a51b..3aae01d 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -124,6 +124,7 @@ + diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index 7e481b8..accbd62 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -171,7 +171,10 @@ // 読んでいる最中にコールバックスレッドが Dispose して別スレッド同時アクセスでクラッシュしうる。 // そのため ShowImage は入力 bmp を「読むだけ」とし,表示用には呼び出しスレッド上で // Form1 所有のコピーを生成する(bmp の所有権は呼び出し側に残す)。 - var dispCopy = new Bitmap(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 @@ -205,11 +208,11 @@ #if MEMMONITOR PreviewProfiler.IsSubShown = true; long subCopyStart = System.Diagnostics.Stopwatch.GetTimestamp(); - var subCopy = new Bitmap(bmp); + var subCopy = BitmapUtil.FastClone(bmp); PreviewProfiler.AddSubCopyTicks(System.Diagnostics.Stopwatch.GetTimestamp() - subCopyStart); _previewMonitor.UpdateImage(subCopy); #else - _previewMonitor.UpdateImage(new Bitmap(bmp)); + _previewMonitor.UpdateImage(BitmapUtil.FastClone(bmp)); #endif } #if MEMMONITOR diff --git a/TIASshot/UI/MemoryMonitorForm.cs b/TIASshot/UI/MemoryMonitorForm.cs index 0be6cd6..7e9fc51 100644 --- a/TIASshot/UI/MemoryMonitorForm.cs +++ b/TIASshot/UI/MemoryMonitorForm.cs @@ -30,7 +30,7 @@ // プレビュー計測(コピー負荷 vs UI 描画負荷の切り分け用。PreviewProfiler.Snapshot() の区間値) public double CallbackFps; // ShowImage 実効フレームレート(コールバックスレッド律速の指標) - public double MainPaintFps; // Form1_Paint の実スワップレート + public double MainPaintFps; // MainRefreshTimer_Tick の実スワップレート public double SubUpdateFps; // PreviewMonitor.UpdateImage 呼び出しレート public double SubPaintFps; // PreviewMonitor_Paint の実スワップレート(副モニタ描画側の指標) public double SubDropFps; // 副モニタ側の取りこぼしレート diff --git a/TIASshot/UI/PreviewProfiler.cs b/TIASshot/UI/PreviewProfiler.cs index 77cb6b1..4ad43d8 100644 --- a/TIASshot/UI/PreviewProfiler.cs +++ b/TIASshot/UI/PreviewProfiler.cs @@ -7,7 +7,7 @@ /// /// プレビュー表示経路の性能計測(MEMMONITOR 限定)。 - /// カメラのコールバックスレッド(Form1.ShowImage)と UI スレッド(Form1_Paint 等)の + /// カメラのコールバックスレッド(Form1.ShowImage)と UI スレッド(MainRefreshTimer_Tick 等)の /// 双方から呼ばれるため,すべて Interlocked/lock で加算するスレッド安全なカウンタとする。 /// 「コピー負荷(コールバックスレッド)」「副モニタの実描画コスト」「GDI/USER ハンドル枯渇」 /// 「プレビュー画像サイズ肥大」のどれが副モニタ描画崩壊の原因かを切り分けるために, @@ -17,9 +17,9 @@ // ---- カウンタ(区間内の呼び出し回数)---------------------------------- private static long _callbackFrames; // ShowImage 呼び出し数(コールバックスレッドの実効フレーム数) - private static long _mainPaints; // Form1_Paint で実際に picDisplay.Image を差し替えた回数 + private static long _mainPaints; // MainRefreshTimer_Tick で実際に picDisplay.Image を差し替えた回数 private static long _subUpdates; // PreviewMonitor.UpdateImage 呼び出し数 - private static long _subPaints; // PreviewMonitor_Paint で実際に picPreview.Image を差し替えた回数 + private static long _subPaints; // RefreshTimer_Tick で実際に picPreview.Image を差し替えた回数 private static long _subDrops; // UpdateImage で旧バッファを取りこぼし Dispose した回数 // ---- コピー時間累積(Stopwatch tick)---------------------------------- diff --git a/TIASshot/Utils/BitmapUtil.cs b/TIASshot/Utils/BitmapUtil.cs new file mode 100644 index 0000000..92eb2c6 --- /dev/null +++ b/TIASshot/Utils/BitmapUtil.cs @@ -0,0 +1,68 @@ +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+ フルコピーにフォールバックする。 + // プレビュー画像(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/PROGRESS.md b/docs/PROGRESS.md index 863a771..0890c56 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -84,4 +84,8 @@ - 失敗パターンの圧縮(対策): ① 副モニタのみタイマー駆動化 → **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)が律速。コピー高速化(`new Bitmap`→LockBits memcpy)は本コミット後に別途試行予定。Phase 2/3(自動テスト・リファクタ)は本変更が WinForms UI スレッド・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+ フルコピーにフォールバック。