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 ライフサイクル・カメラコールバック並行性主体で既存ユニットテスト基盤の対象外のため、実機メモリ計測での検証に代替(ユーザー指示によりこの検証済み状態でコミット)。