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+ フルコピーにフォールバック。