diff --git a/CLAUDE.md b/CLAUDE.md index e375fcf..76e736e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 メモリ修正の効果検証に「連続撮影フロー再現ベンチ」(合成フレーム・生産者消費者+多チャンネル)を追加し計測結果を TEST_01 に記録 +最新: PLAN_03 プレビューメモリリーク(原因1〜3)を修正.派生で発覚した Lucam のクロススレッド GDI クラッシュ・副モニタ描画飢餓も修正し,両プレビューをタイマー駆動化(MEMMONITOR にプレビュー計測を追加) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) 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..cf4a51b 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -144,6 +144,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..7e481b8 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,87 @@ /// /// 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 の所有権は呼び出し側に残す)。 + var dispCopy = new Bitmap(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 = new Bitmap(bmp); + PreviewProfiler.AddSubCopyTicks(System.Diagnostics.Stopwatch.GetTimestamp() - subCopyStart); + _previewMonitor.UpdateImage(subCopy); +#else + _previewMonitor.UpdateImage(new Bitmap(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..0be6cd6 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; // Form1_Paint の実スワップレート + 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..77cb6b1 --- /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 スレッド(Form1_Paint 等)の + /// 双方から呼ばれるため,すべて Interlocked/lock で加算するスレッド安全なカウンタとする。 + /// 「コピー負荷(コールバックスレッド)」「副モニタの実描画コスト」「GDI/USER ハンドル枯渇」 + /// 「プレビュー画像サイズ肥大」のどれが副モニタ描画崩壊の原因かを切り分けるために, + /// 各段の呼び出し回数・所要時間・イベント(校正/撮影/F9 トグル)を計測する。 + /// + internal static class PreviewProfiler { + + // ---- カウンタ(区間内の呼び出し回数)---------------------------------- + private static long _callbackFrames; // ShowImage 呼び出し数(コールバックスレッドの実効フレーム数) + private static long _mainPaints; // Form1_Paint で実際に picDisplay.Image を差し替えた回数 + private static long _subUpdates; // PreviewMonitor.UpdateImage 呼び出し数 + private static long _subPaints; // PreviewMonitor_Paint で実際に 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/docs/PROGRESS.md b/docs/PROGRESS.md index a13e6ab..863a771 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -76,3 +76,12 @@ - 設計判断: 本番 `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)が律速。コピー高速化(`new Bitmap`→LockBits memcpy)は本コミット後に別途試行予定。Phase 2/3(自動テスト・リファクタ)は本変更が WinForms UI スレッド・GDI ライフサイクル・カメラコールバック並行性主体で既存ユニットテスト基盤の対象外のため、実機メモリ計測での検証に代替(ユーザー指示によりこの検証済み状態でコミット)。