diff --git a/CLAUDE.md b/CLAUDE.md index 67714a0..a77eb48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: 白板撮影モードの出し分けを `#if DEBUG` から専用ビルド記号 `WHITEBOARD` に変更(Designer の条件コンパイル全廃・白板ボタン配置修正) +最新: PLAN_02 メモリ修正の効果検証用にメモリ観測ウィンドウ(MEMMONITOR)とリーク再現モード(LEAKY_REPRO)を統合(実アプリのメモリをライブ可視化・対比録画用) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) diff --git a/TIASshot/ColorCorrection/ColorCorrector.cs b/TIASshot/ColorCorrection/ColorCorrector.cs index 42bb2ff..a578385 100644 --- a/TIASshot/ColorCorrection/ColorCorrector.cs +++ b/TIASshot/ColorCorrection/ColorCorrector.cs @@ -181,12 +181,34 @@ } /// - /// 画像の色変換 + /// 画像の色変換. + /// + /// LEAKY_REPRO 定義時は優先度2修正前(中間 Mat 未解放)の挙動に戻すデモ用分岐。 + /// LEAKY_REPRO を定義してビルドすると flatten / extended / converted / convertedImage を + /// Dispose せずに返すリーク実装(git 0003cc2^ 相当)になり,撮影を繰り返すと + /// メモリ観測ウィンドウ上でメモリが増加し続ける様子を録画で確認できる。 + /// 通常ビルド(LEAKY_REPRO 未定義)では入れ子 using による修正後実装が使われる。 + /// /// /// /// - /// double型画像 + /// 8bit 画像 internal Mat ConvertImage(Mat src, Mat conv) { +#if LEAKY_REPRO + // ---- リーク版(LEAKY_REPRO): 修正前の挙動を忠実に再現(中間 Mat を Dispose しない)---- + if (src.Type() != MatType.CV_64FC3) { + src.ConvertTo(src, MatType.CV_64FC3); + } + var flatten = src.Reshape(3, src.Height * src.Width); + var extended = ExtendMat(flatten, conv.Rows); + var converted = (extended * conv).ToMat(); + var convertedImage = converted.Reshape(3, src.Height); + + var convImg8 = new Mat(); + convertedImage.ConvertTo(convImg8, MatType.CV_8UC3); + return convImg8; // flatten / extended / converted / convertedImage は Dispose しない(=リーク) +#else + // ---- 修正後(通常ビルド): 中間 Mat を入れ子 using で解放 ---- if (src.Type() != MatType.CV_64FC3) { src.ConvertTo(src, MatType.CV_64FC3); } @@ -203,6 +225,7 @@ convertedImage.ConvertTo(convImg8, MatType.CV_8UC3); return convImg8; } +#endif } /// diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index 9a3cd3a..d78ffc0 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -22,7 +22,7 @@ full false bin\Debug\ - DEBUG;TRACE;WHITEBOARD + DEBUG;TRACE;WHITEBOARD;MEMMONITOR prompt 4 true @@ -40,7 +40,7 @@ true bin\x64\Debug\ - DEBUG;TRACE;WHITEBOARD + DEBUG;TRACE;WHITEBOARD;MEMMONITOR full x64 7.3 @@ -110,6 +110,7 @@ + False @@ -134,6 +135,15 @@ Form1.cs + + Form1.cs + + + Form + + + MemoryMonitorForm.cs + diff --git a/TIASshot/UI/Form1.MemMonitor.cs b/TIASshot/UI/Form1.MemMonitor.cs new file mode 100644 index 0000000..c341f94 --- /dev/null +++ b/TIASshot/UI/Form1.MemMonitor.cs @@ -0,0 +1,32 @@ +#if MEMMONITOR +using System.Windows.Forms; + +namespace TIASshot { + public partial class Form1 { + + private MemoryMonitorForm _memoryMonitorForm; + + /// + /// メモリ観測ウィンドウを非モーダルで開く(MEMMONITOR 限定)。 + /// 既に開いている場合は前面化する。 + /// Shown イベントから呼び出すことでメインフォームの最終位置を確定させてから配置する。 + /// + private void ShowMemoryMonitor() { + if (_memoryMonitorForm != null && !_memoryMonitorForm.IsDisposed) { + _memoryMonitorForm.BringToFront(); + return; + } + + _memoryMonitorForm = new MemoryMonitorForm(); + + // メインフォームの右隣に配置 + _memoryMonitorForm.StartPosition = FormStartPosition.Manual; + _memoryMonitorForm.Location = new System.Drawing.Point( + this.Right + 8, + this.Top); + + _memoryMonitorForm.Show(this); + } + } +} +#endif diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index 7cf2b2c..152a677 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -34,6 +34,9 @@ #if WHITEBOARD InitializeWhiteBoardButton(); #endif +#if MEMMONITOR + this.Shown += (s, e) => ShowMemoryMonitor(); +#endif var version = Assembly.GetExecutingAssembly().GetName().Version; Text = $"{APP_NAME} ver.{version.Major}.{version.Minor}"; diff --git a/TIASshot/UI/MemoryMonitorForm.Designer.cs b/TIASshot/UI/MemoryMonitorForm.Designer.cs new file mode 100644 index 0000000..2920b24 --- /dev/null +++ b/TIASshot/UI/MemoryMonitorForm.Designer.cs @@ -0,0 +1,148 @@ +#if MEMMONITOR +namespace TIASshot { + partial class MemoryMonitorForm { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) { + if (disposing && (components != null)) { + components.Dispose(); + } + base.Dispose(disposing); + } + + private void InitializeComponent() { + System.Windows.Forms.DataVisualization.Charting.ChartArea chartArea = new System.Windows.Forms.DataVisualization.Charting.ChartArea(); + System.Windows.Forms.DataVisualization.Charting.Legend legend = new System.Windows.Forms.DataVisualization.Charting.Legend(); + System.Windows.Forms.DataVisualization.Charting.Series seriesPrivate = new System.Windows.Forms.DataVisualization.Charting.Series(); + System.Windows.Forms.DataVisualization.Charting.Series seriesWS = new System.Windows.Forms.DataVisualization.Charting.Series(); + + this.chart = new System.Windows.Forms.DataVisualization.Charting.Chart(); + this.lblCurrent = 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(); + this.btnSaveCsv = new System.Windows.Forms.Button(); + this.btnOpenFolder = new System.Windows.Forms.Button(); + this.panelButtons = new System.Windows.Forms.Panel(); + + ((System.ComponentModel.ISupportInitialize)(this.chart)).BeginInit(); + this.panelButtons.SuspendLayout(); + this.SuspendLayout(); + + // chart + chartArea.Name = "ChartArea1"; + chartArea.AxisX.Title = "経過時間 (秒)"; + chartArea.AxisY.Title = "メモリ (GB)"; + chartArea.AxisX.IsStartedFromZero = true; + chartArea.AxisY.IsStartedFromZero = true; + this.chart.ChartAreas.Add(chartArea); + + legend.Name = "Legend1"; + this.chart.Legends.Add(legend); + + seriesPrivate.Name = "Private"; + seriesPrivate.ChartType = System.Windows.Forms.DataVisualization.Charting.SeriesChartType.Line; + seriesPrivate.XValueType = System.Windows.Forms.DataVisualization.Charting.ChartValueType.Double; + seriesPrivate.YValueType = System.Windows.Forms.DataVisualization.Charting.ChartValueType.Double; + seriesPrivate.BorderWidth = 2; + seriesPrivate.Color = System.Drawing.Color.DodgerBlue; + + seriesWS.Name = "WorkingSet"; + seriesWS.ChartType = System.Windows.Forms.DataVisualization.Charting.SeriesChartType.Line; + seriesWS.XValueType = System.Windows.Forms.DataVisualization.Charting.ChartValueType.Double; + seriesWS.YValueType = System.Windows.Forms.DataVisualization.Charting.ChartValueType.Double; + seriesWS.BorderWidth = 2; + seriesWS.Color = System.Drawing.Color.OrangeRed; + + this.chart.Series.Add(seriesPrivate); + this.chart.Series.Add(seriesWS); + this.chart.Dock = System.Windows.Forms.DockStyle.Fill; + this.chart.Name = "chart"; + this.chart.TabIndex = 0; + + // lblMode(Text / ForeColor は MemoryMonitorForm.cs コンストラクタで設定) + this.lblMode.AutoSize = false; + this.lblMode.Dock = System.Windows.Forms.DockStyle.Top; + this.lblMode.Font = new System.Drawing.Font("Consolas", 10F, System.Drawing.FontStyle.Bold); + this.lblMode.Height = 26; + this.lblMode.Name = "lblMode"; + this.lblMode.TabIndex = 3; + this.lblMode.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + this.lblMode.Padding = new System.Windows.Forms.Padding(4, 0, 0, 0); + + // lblCurrent + this.lblCurrent.AutoSize = false; + this.lblCurrent.Dock = System.Windows.Forms.DockStyle.Top; + this.lblCurrent.Font = new System.Drawing.Font("Consolas", 11F, System.Drawing.FontStyle.Bold); + this.lblCurrent.Height = 32; + this.lblCurrent.Name = "lblCurrent"; + this.lblCurrent.TabIndex = 1; + this.lblCurrent.Text = "現在: --- (待機中)"; + this.lblCurrent.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + this.lblCurrent.Padding = new System.Windows.Forms.Padding(4, 0, 0, 0); + + // panelButtons + this.btnStartStop.Name = "btnStartStop"; + this.btnStartStop.Size = new System.Drawing.Size(100, 28); + this.btnStartStop.Location = new System.Drawing.Point(4, 4); + this.btnStartStop.Text = "停止"; + this.btnStartStop.TabIndex = 0; + this.btnStartStop.Click += new System.EventHandler(this.btnStartStop_Click); + + this.btnReset.Name = "btnReset"; + this.btnReset.Size = new System.Drawing.Size(80, 28); + this.btnReset.Location = new System.Drawing.Point(112, 4); + this.btnReset.Text = "リセット"; + this.btnReset.TabIndex = 1; + this.btnReset.Click += new System.EventHandler(this.btnReset_Click); + + this.btnSaveCsv.Name = "btnSaveCsv"; + this.btnSaveCsv.Size = new System.Drawing.Size(100, 28); + this.btnSaveCsv.Location = new System.Drawing.Point(200, 4); + this.btnSaveCsv.Text = "CSV保存"; + this.btnSaveCsv.TabIndex = 2; + this.btnSaveCsv.Click += new System.EventHandler(this.btnSaveCsv_Click); + + this.btnOpenFolder.Name = "btnOpenFolder"; + this.btnOpenFolder.Size = new System.Drawing.Size(120, 28); + this.btnOpenFolder.Location = new System.Drawing.Point(308, 4); + this.btnOpenFolder.Text = "フォルダを開く"; + this.btnOpenFolder.TabIndex = 3; + this.btnOpenFolder.Click += new System.EventHandler(this.btnOpenFolder_Click); + + this.panelButtons.Controls.Add(this.btnStartStop); + this.panelButtons.Controls.Add(this.btnReset); + this.panelButtons.Controls.Add(this.btnSaveCsv); + this.panelButtons.Controls.Add(this.btnOpenFolder); + this.panelButtons.Dock = System.Windows.Forms.DockStyle.Bottom; + this.panelButtons.Height = 38; + this.panelButtons.Name = "panelButtons"; + this.panelButtons.TabIndex = 2; + + // MemoryMonitorForm + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(640, 480); + this.Controls.Add(this.chart); + this.Controls.Add(this.lblCurrent); + this.Controls.Add(this.lblMode); + this.Controls.Add(this.panelButtons); + this.Name = "MemoryMonitorForm"; + // Text は MemoryMonitorForm.cs コンストラクタで設定 + + ((System.ComponentModel.ISupportInitialize)(this.chart)).EndInit(); + this.panelButtons.ResumeLayout(false); + this.ResumeLayout(false); + } + + private System.Windows.Forms.DataVisualization.Charting.Chart chart; + private System.Windows.Forms.Label lblCurrent; + private System.Windows.Forms.Label lblMode; + private System.Windows.Forms.Button btnStartStop; + private System.Windows.Forms.Button btnReset; + private System.Windows.Forms.Button btnSaveCsv; + private System.Windows.Forms.Button btnOpenFolder; + private System.Windows.Forms.Panel panelButtons; + } +} +#endif diff --git a/TIASshot/UI/MemoryMonitorForm.cs b/TIASshot/UI/MemoryMonitorForm.cs new file mode 100644 index 0000000..39fb0bc --- /dev/null +++ b/TIASshot/UI/MemoryMonitorForm.cs @@ -0,0 +1,178 @@ +#if MEMMONITOR +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Windows.Forms; + +namespace TIASshot { + + /// + /// メモリ観測ウィンドウ(MEMMONITOR 限定)。 + /// TIASshot 本体プロセスのメモリ使用量をライブ折れ線グラフで表示する。 + /// 研究室デモ用:実使用時にメモリが増え続けないことを画面録画で示す。 + /// + public partial class MemoryMonitorForm : Form { + + // ---- サンプリング記録 ------------------------------------------------- + private struct MemSample { + public double ElapsedSec; + public double PrivateMB; + public double WorkingSetMB; + } + + private readonly List _samples = new List(); + private readonly System.Windows.Forms.Timer _timer; + private readonly Stopwatch _stopwatch = new Stopwatch(); + private bool _isRunning; + + // CSV 出力先 + private readonly string _outputDir; + + // ---- コンストラクタ --------------------------------------------------- + + public MemoryMonitorForm() { + InitializeComponent(); + + // モードラベルとウィンドウタイトルをビルド種別に応じて設定 +#if LEAKY_REPRO + lblMode.Text = "モード: リーク再現(修正前)"; + lblMode.ForeColor = System.Drawing.Color.Red; + this.Text = "メモリ観測ウィンドウ [MEMMONITOR] — リーク再現"; +#else + lblMode.Text = "モード: 修正後"; + lblMode.ForeColor = System.Drawing.Color.Green; + this.Text = "メモリ観測ウィンドウ [MEMMONITOR] — 修正後"; +#endif + + _outputDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, + "MemMonitor_Results"); + Directory.CreateDirectory(_outputDir); + + _timer = new System.Windows.Forms.Timer(); + _timer.Interval = 500; + _timer.Tick += Timer_Tick; + + this.FormClosing += (s, e) => { + _timer.Stop(); + }; + + // ウィンドウを開いたら自動でサンプリング開始 + this.Shown += (s, e) => StartSampling(); + } + + // ---- サンプリング制御 ------------------------------------------------ + + private void StartSampling() { + if (_isRunning) return; + _isRunning = true; + _stopwatch.Start(); + _timer.Start(); + btnStartStop.Text = "停止"; + } + + private void StopSampling() { + if (!_isRunning) return; + _isRunning = false; + _stopwatch.Stop(); + _timer.Stop(); + btnStartStop.Text = "開始"; + } + + // ---- タイマー Tick(サンプリング本体)-------------------------------- + + private void Timer_Tick(object sender, EventArgs e) { + // Process.Refresh() で最新値を取得(GC 強制なし) + using (var proc = Process.GetCurrentProcess()) { + proc.Refresh(); + + double elapsedSec = _stopwatch.Elapsed.TotalSeconds; + double privateMB = proc.PrivateMemorySize64 / (1024.0 * 1024.0); + double wsMB = proc.WorkingSet64 / (1024.0 * 1024.0); + + // 記録 + _samples.Add(new MemSample { + ElapsedSec = elapsedSec, + PrivateMB = privateMB, + WorkingSetMB = wsMB, + }); + + // グラフ追加 + double privateGB = privateMB / 1024.0; + double wsGB = wsMB / 1024.0; + + chart.Series["Private"].Points.AddXY(elapsedSec, privateGB); + chart.Series["WorkingSet"].Points.AddXY(elapsedSec, wsGB); + + // Y 軸オートスケール追従 + chart.ChartAreas["ChartArea1"].RecalculateAxesScale(); + + // ラベル更新 + int n = (int)Math.Round(elapsedSec); + lblCurrent.Text = + $"現在: Private {privateGB:F2} GB / WS {wsGB:F2} GB(経過 {n}s)"; + } + } + + // ---- ボタンイベント -------------------------------------------------- + + private void btnStartStop_Click(object sender, EventArgs e) { + if (_isRunning) { + StopSampling(); + } else { + StartSampling(); + } + } + + private void btnReset_Click(object sender, EventArgs e) { + StopSampling(); + _samples.Clear(); + _stopwatch.Reset(); + chart.Series["Private"].Points.Clear(); + chart.Series["WorkingSet"].Points.Clear(); + lblCurrent.Text = "現在: --- (リセット)"; + } + + private void btnSaveCsv_Click(object sender, EventArgs e) { + if (_samples.Count == 0) { + MessageBox.Show("記録データがありません。", "CSV 保存", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + string path = NextCsvPath(); + var sb = new StringBuilder(); + sb.AppendLine("elapsed_sec,private_MB,ws_MB"); + foreach (var s in _samples) { + sb.AppendLine($"{s.ElapsedSec:F3},{s.PrivateMB:F2},{s.WorkingSetMB:F2}"); + } + File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + + MessageBox.Show($"保存しました:\n{path}", "CSV 保存", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + private void btnOpenFolder_Click(object sender, EventArgs e) { + Directory.CreateDirectory(_outputDir); + Process.Start("explorer.exe", _outputDir); + } + + // ---- ユーティリティ -------------------------------------------------- + + private string NextCsvPath() { + const string prefix = "mem_monitor_"; + int max = 0; + if (Directory.Exists(_outputDir)) { + foreach (var f in Directory.GetFiles(_outputDir, prefix + "*.csv")) { + string name = Path.GetFileNameWithoutExtension(f); + string numPart = name.Substring(prefix.Length); + if (int.TryParse(numPart, out int n) && n > max) max = n; + } + } + return Path.Combine(_outputDir, $"{prefix}{max + 1:D4}.csv"); + } + } +} +#endif diff --git "a/docs/03_PLAN/PLAN_02_\343\203\220\343\202\260\344\277\256\346\255\243\350\250\210\347\224\273.md" "b/docs/03_PLAN/PLAN_02_\343\203\220\343\202\260\344\277\256\346\255\243\350\250\210\347\224\273.md" index a9b0a0f..d67aebe 100644 --- "a/docs/03_PLAN/PLAN_02_\343\203\220\343\202\260\344\277\256\346\255\243\350\250\210\347\224\273.md" +++ "b/docs/03_PLAN/PLAN_02_\343\203\220\343\202\260\344\277\256\346\255\243\350\250\210\347\224\273.md" @@ -167,3 +167,18 @@ | 4 | `ConvertImage` の in-place 書き換え | `ColorCorrector.cs` | 誤動作(変換結果の誤り) | | 5 | `_shots` / `_chartMasks` の Mat 未 Dispose | 各 `Shot` / `CameraBase.cs` | メモリリーク(蓄積) | | 6 | `Form1` の `InvokeRequired` 戻り値欠落 | `Form1.cs` | 別系統(現状潜在・クラッシュとは無関係) | + +## 効果検証(メモリ修正)の方法 (Verifying the Memory Fixes) + +優先度2(`ConvertImage` の中間 Mat 解放)の効果を、定量・デモの両面で検証できるようにしてある.いずれも専用のコンパイル記号で出し分け,本番(Release)には含めない. + +### 定量比較(自動・数値) + +- `TIASshot.Tests/ConvertImageMemoryBenchmark.cs`(NUnit・`[Explicit]`)で,修正前(リーク版を忠実に再現)と修正後(実 `ConvertImage`)を同条件で反復し,`Process.PrivateMemorySize64` / `WorkingSet64` の baseline・peak・増分を比較する.通常テスト実行では走らず,明示指定で実行する(実行コマンドはファイル冒頭コメント参照). +- Mat はネイティブメモリのため `GC.GetTotalMemory` では捕捉できない.計測ループ中は GC を呼ばない(本番のリーク機序を再現するため). + +### デモ(実アプリのメモリをライブ可視化) + +- コンパイル記号 **`MEMMONITOR`**: 定義してビルドすると,TIASshot 起動時に「メモリ観測ウィンドウ」(`UI/MemoryMonitorForm`)が自動で開く.`System.Windows.Forms.Timer` で実プロセスのメモリを定期サンプリングし,ライブ折れ線グラフ+現在値で表示,CSV 保存も可能.実際に撮影しながら実使用時のメモリ挙動を録画できる.Release には含めない(既定で Debug 構成のみ定義). +- コンパイル記号 **`LEAKY_REPRO`**: 定義してビルドすると,`ColorCorrector.ConvertImage` が優先度2修正前(中間 Mat 未解放)の挙動に戻る.観測ウィンドウのモード表示も「リーク再現(修正前)」に切り替わる.既定ではどの構成にも未定義(=修正後)で,対比録画したいときだけ一時的に定義する. +- 依存: 観測ウィンドウは `System.Windows.Forms.DataVisualization`(Chart)を使用する. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 6384396..e7bc2f8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -65,3 +65,9 @@ - 動機: 「デバッグビルドか」と「白板撮影を含めるか」は別関心事なのに DEBUG で結合していた。本番(Release)からの除外は維持しつつ独立制御できるようにする。Designer.cs に `#if` が入っていると WinForms デザイナで破損するリスクがあったため全廃が主目的。 - 設計判断: 専用ビルド記号案 vs 実行時 config フラグ案。本番バイナリにコードごと残さず除外したい要件から前者を採用(Debug 構成にのみ `WHITEBOARD` を定義、Release は未定義)。 - UI: 白板ボタン配置は試行の末「1 枚撮影」直下の空き帯に収めた(フォーム下端拡張案は「下すぎる」ため不採用)。WHITEBOARD ビルド時のみ実行時に動的追加するため本番レイアウトは不変。 + +- [x] **PLAN_02 メモリ修正の効果検証ツール(NUnit ベンチ+実アプリ統合のメモリ観測ウィンドウ)** (2026-06-23): 優先度2(`ConvertImage` 中間 Mat 解放)の効果を定量・デモ両面で検証できるようにした。定量は NUnit `[Explicit]` ベンチ(修正前リーク再現 vs 修正後を同条件反復し PrivateMemory/WS の peak・増分を比較)。デモは TIASshot 本体に統合したメモリ観測ウィンドウ(`MEMMONITOR` 記号・起動時自動オープン・Timer サンプリングのライブ折れ線+CSV)で実使用時のメモリをライブ可視化。 + - 動機: 研究室への成果共有のため、実アプリを普通に使った(撮影した)ときのメモリ挙動を録画して「修正前→修正後」を対比で見せたい、という要件。当初は独立実験アプリ(合成ベンチ GUI)を作ったが、用途に合わず本体統合へ転換。 + - 設計判断(失敗パターン圧縮): ① 独立実験アプリ案 → 「普通にアプリを使ったときの挙動」が見せられず破棄 ② 本体統合のライブ観測ウィンドウへ。出し分けは白板と同じビルド記号方式(`MEMMONITOR`、Release 除外)。さらに対比録画用に `LEAKY_REPRO` 記号で `ConvertImage` を修正前(中間 Mat 未解放)に戻せるようにした(既定未定義=修正後)。 + - 計測の要点: Mat はネイティブメモリで `GC.GetTotalMemory` では捕捉不可 → `Process.PrivateMemorySize64`/`WorkingSet64` を使用。OpenCvSharp が GC にメモリ圧を通知するためリーク版でも GC+ファイナライザで周期回収が起き、グラフはのこぎり波になる(見るべきは振れ幅でなくピーク包絡線のトレンド:修正後=天井一定、リーク=上昇)。 + - 後段: 優先度5(`_shots`/`_chartMasks` の Clear 時未解放)は未修正のため LEAKY_REPRO の対象外(対比は優先度2のみ)。グラフのトレンド可視化(包絡線・移動平均等)は要望次第で追加余地。