diff --git a/CLAUDE.md b/CLAUDE.md index 67714a0..e375fcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: 白板撮影モードの出し分けを `#if DEBUG` から専用ビルド記号 `WHITEBOARD` に変更(Designer の条件コンパイル全廃・白板ボタン配置修正) +最新: PLAN_02 メモリ修正の効果検証に「連続撮影フロー再現ベンチ」(合成フレーム・生産者消費者+多チャンネル)を追加し計測結果を TEST_01 に記録 ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) diff --git a/TIASshot.Tests/ContinuousShootMemoryBenchmark.cs b/TIASshot.Tests/ContinuousShootMemoryBenchmark.cs new file mode 100644 index 0000000..27273e5 --- /dev/null +++ b/TIASshot.Tests/ContinuousShootMemoryBenchmark.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using NUnit.Framework; +using OpenCvSharp; + +namespace TIASshot.Tests { + /// + /// 「連続撮影フロー」を合成フレームで再現するメモリ計測ベンチ(PLAN_02 優先度2/5 検証). + /// + /// 【重要】これは本番メソッド(CameraBase.Shot/RunShotLoop/SaveThread/SaveImages) + /// そのものの呼び出しではなく,カメラ取得部分(CaptureFrame)のみを合成フレームに + /// 差し替えた連続撮影フローの「構造再現」である.本番コード + /// (CameraBase / ColorCorrector / IScam 等)は一切改造していない. + /// ColorCorrector の internal メンバは読み取り利用のみ. + /// + /// 既存の単体ループベンチ(ConvertImageMemoryBenchmark)より本番に近く,以下を再現する: + /// - 生産者-消費者スレッディング(BlockingCollection + consumer Thread) + /// - 1 フレームを複数チャンネルで変換(SaveImages の foreach channel 相当) + /// - フレームバッファ(shots に積まれた img は Dispose されない=優先度5 未修正) + /// + /// 修正前(リーク版・優先度2 未修正)と修正後(using 版)を同条件で実行し, + /// プロセスのメモリ使用量(PrivateMemorySize64 / WorkingSet64)の挙動を比較する. + /// + /// 計測作法は ConvertImageMemoryBenchmark を踏襲: + /// - Process.PrivateMemorySize64 / WorkingSet64 をサンプリング + /// - 計測中に GC を呼ばない(未 Dispose の Mat ネイティブメモリ積み上がりを観測する機序) + /// - baseline 前に ReclaimNative()(GC.Collect → WaitForPendingFinalizers → GC.Collect) + /// - 物理メモリ枯渇を防ぐ打ち切り上限ガード(ceiling 超過で producer を停止) + /// + /// 通常テスト実行では走らない([Explicit] + Category("Benchmark")). + /// + /// 環境変数でスケール調整可: + /// CSB_WIDTH 画像幅 (既定 2440) + /// CSB_HEIGHT 画像高 (既定 1840) + /// CSB_CHANNELS 変換チャネル一覧 (既定 "4,10,17") + /// CSB_FRAMES 撮影枚数 (既定 8) + /// CSB_INTERVAL_MS 撮影間隔(ms) (既定 0) + /// CSB_CEILING_GB 打ち切り上限GB (既定 10) + /// CSB_WRITE_FILES ファイル書き出し (既定 false) + /// + [TestFixture] + [Explicit("メモリ計測ベンチ.連続撮影フローを合成フレームで構造再現するため明示実行のみ.")] + [Category("Benchmark")] + public class ContinuousShootMemoryBenchmark { + + // ---- 環境変数ヘルパー(EnvInt / EnvDouble は MemBenchSupport を利用) ---- + + static bool EnvBool(string name, bool fallback) { + var v = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(v)) return fallback; + if (bool.TryParse(v, out var b)) return b; + return v.Trim() == "1"; + } + + static int[] EnvIntArray(string name, int[] fallback) { + var v = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(v)) return fallback; + var list = new List(); + foreach (var part in v.Split(',')) { + if (int.TryParse(part.Trim(), out var n)) list.Add(n); + } + return list.Count > 0 ? list.ToArray() : fallback; + } + + // ---- 合成フレーム ----------------------------------------------------- + + /// + /// 合成フレーム(カメラ CaptureFrame の代替).本番の CV_8UC3 ROI フレーム相当. + /// + static Mat MakeSyntheticFrame(int width, int height) { + var m = new Mat(height, width, MatType.CV_8UC3); + Cv2.Randu(m, new Scalar(0), new Scalar(255)); + return m; + } + + // ---- 結果保持 --------------------------------------------------------- + + class VariantResult { + public long BasePriv, BaseWs, PeakPriv, PeakWs; + public int FramesProcessed; + public bool Aborted; + } + + sealed class MemSample { + public double ElapsedSec; + public long Priv; + public long Ws; + public string Variant; + } + + [Test] + public void Compare_LeakyVsFixed_ContinuousShoot() { + int width = MemBenchSupport.EnvInt("CSB_WIDTH", 2440); + int height = MemBenchSupport.EnvInt("CSB_HEIGHT", 1840); + int[] channels = EnvIntArray("CSB_CHANNELS", new int[] { 4, 10, 17 }); + int frames = MemBenchSupport.EnvInt("CSB_FRAMES", 8); + int interval = MemBenchSupport.EnvInt("CSB_INTERVAL_MS", 0); + long ceiling = (long)(MemBenchSupport.EnvDouble("CSB_CEILING_GB", 10.0) * 1024 * 1024 * 1024); + bool writeFiles = EnvBool("CSB_WRITE_FILES", false); + + // ColorCorrector(テスト用コンストラクタ: チャネル配列を直接渡す). + // 本番コードは無改造,internal を読み取り利用するのみ. + var corrector = new ColorCorrector(null, null, channels); + + // 各チャネルの変換行列(channel x 3)を事前生成.値は任意(メモリ挙動に影響しない). + var convMats = new Dictionary(); + foreach (var ch in channels) { + convMats[ch] = new Mat(ch, 3, MatType.CV_64FC1, new Scalar(0.01)); + } + + // 時系列サンプルを全バリアント分まとめて CSV 出力する. + var allSamples = new ConcurrentQueue(); + + try { + TestContext.WriteLine("=== 連続撮影フロー メモリ計測ベンチ(合成フレーム・構造再現) ==="); + TestContext.WriteLine("※ 本ベンチはカメラ取得部分のみ合成フレームに差し替えた連続撮影フローの"); + TestContext.WriteLine("  構造再現であり,本番メソッド(Shot/RunShotLoop/SaveThread)そのものの呼び出しではない."); + TestContext.WriteLine(""); + TestContext.WriteLine($"条件: {width}x{height} CV_8UC3 / channels=[{string.Join(",", channels)}] / " + + $"frames={frames} / interval={interval}ms / write_files={writeFiles}"); + TestContext.WriteLine($"打ち切り上限: {MemBenchSupport.GB(ceiling):F1} GB (PrivateMemory)"); + TestContext.WriteLine(""); + + // --- 修正後(using 版)を先にクリーンな状態で計測 --- + var fixedResult = RunVariant( + "修正後(using 版)", + leaky: false, + corrector, convMats, channels, + width, height, frames, interval, ceiling, writeFiles, allSamples); + + MemBenchSupport.ReclaimNative(); + + // --- 修正前(リーク版)を計測 --- + var leakyResult = RunVariant( + "修正前(リーク版)", + leaky: true, + corrector, convMats, channels, + width, height, frames, interval, ceiling, writeFiles, allSamples); + + // 放置したネイティブメモリを後始末(後続テストへの影響を防ぐ) + MemBenchSupport.ReclaimNative(); + + // --- 比較サマリ(既存ベンチと同体裁) --- + TestContext.WriteLine(""); + TestContext.WriteLine("================ 比較サマリ ================"); + MemBenchSupport.PrintRow("項目", "修正前(リーク版)", "修正後(using 版)"); + MemBenchSupport.PrintRow("完了/打ち切り", + leakyResult.Aborted ? $"打ち切り({leakyResult.FramesProcessed}f)" : $"完走({leakyResult.FramesProcessed}f)", + fixedResult.Aborted ? $"打ち切り({fixedResult.FramesProcessed}f)" : $"完走({fixedResult.FramesProcessed}f)"); + MemBenchSupport.PrintRow("Private baseline", + $"{MemBenchSupport.GB(leakyResult.BasePriv):F2} GB", + $"{MemBenchSupport.GB(fixedResult.BasePriv):F2} GB"); + MemBenchSupport.PrintRow("Private peak", + $"{MemBenchSupport.GB(leakyResult.PeakPriv):F2} GB", + $"{MemBenchSupport.GB(fixedResult.PeakPriv):F2} GB"); + MemBenchSupport.PrintRow("Private 増分(peak-base)", + $"{MemBenchSupport.GB(leakyResult.PeakPriv - leakyResult.BasePriv):F2} GB", + $"{MemBenchSupport.GB(fixedResult.PeakPriv - fixedResult.BasePriv):F2} GB"); + MemBenchSupport.PrintRow("WorkingSet peak", + $"{MemBenchSupport.GB(leakyResult.PeakWs):F2} GB", + $"{MemBenchSupport.GB(fixedResult.PeakWs):F2} GB"); + MemBenchSupport.PrintRow("1フレームあたり増分", + $"{(leakyResult.FramesProcessed > 0 ? MemBenchSupport.MB(leakyResult.PeakPriv - leakyResult.BasePriv) / leakyResult.FramesProcessed : 0):F0} MB", + $"{(fixedResult.FramesProcessed > 0 ? MemBenchSupport.MB(fixedResult.PeakPriv - fixedResult.BasePriv) / fixedResult.FramesProcessed : 0):F0} MB"); + TestContext.WriteLine("============================================"); + + var leakyGrowth = leakyResult.PeakPriv - leakyResult.BasePriv; + var fixedGrowth = fixedResult.PeakPriv - fixedResult.BasePriv; + TestContext.WriteLine(""); + TestContext.WriteLine( + $"判定: 修正後の増分({MemBenchSupport.GB(fixedGrowth):F2} GB) は 修正前の増分({MemBenchSupport.GB(leakyGrowth):F2} GB) の " + + $"{(leakyGrowth > 0 ? (double)fixedGrowth / leakyGrowth * 100 : 0):F1}% に抑制された."); + TestContext.WriteLine("(注: フレーム自体は両バリアントとも shots に保持され Dispose されない=優先度5 相当の未解放を共通に含む)"); + + // --- 時系列 CSV 出力 --- + var csvPath = WriteCsv(allSamples); + TestContext.WriteLine(""); + TestContext.WriteLine($"時系列 CSV: {csvPath}"); + + Assert.That(fixedGrowth, Is.LessThan(leakyGrowth), + "修正後の方がメモリ増分が小さいこと(修正が効いている)"); + } finally { + foreach (var m in convMats.Values) m.Dispose(); + } + } + + /// + /// 1 つのバリアント(修正前/修正後)について連続撮影フローを構造再現し,メモリ推移を計測する. + /// + /// フロー再現(本番 Shot 相当): + /// 1. ReclaimNative() → baseline 測定. + /// 2. BlockingCollection 生成. + /// 3. consumer スレッド起動(SaveThread 相当). + /// 4. producer(RunShotLoop 相当・本メソッドのメインスレッド)でフレーム投入. + /// 5. consumer を Join. + /// サンプラースレッドは別途並行して ~200ms ごとにメモリを記録し ceiling 監視する. + /// + VariantResult RunVariant( + string label, bool leaky, + ColorCorrector corrector, Dictionary convMats, int[] channels, + int width, int height, int frames, int interval, long ceiling, bool writeFiles, + ConcurrentQueue allSamples) { + + // 1. baseline + MemBenchSupport.ReclaimNative(); + var (basePriv, baseWs) = MemBenchSupport.Sample(); + var r = new VariantResult { + BasePriv = basePriv, BaseWs = baseWs, + PeakPriv = basePriv, PeakWs = baseWs + }; + + TestContext.WriteLine($"--- {label} ---"); + TestContext.WriteLine($"baseline: Private {MemBenchSupport.GB(basePriv):F2} GB / WS {MemBenchSupport.GB(baseWs):F2} GB"); + + // 共有状態(スレッド間で保護) + var peakLock = new object(); + long abortFlag = 0; // Interlocked: ceiling 超過で 1 + long framesProcessed = 0; // Interlocked: consumer が処理したフレーム数 + var pipelineDone = new ManualResetEventSlim(false); + + // 一時フォルダ(ファイル書き出し時のみ) + string tmpFolder = null; + if (writeFiles) { + tmpFolder = Path.Combine(Path.GetTempPath(), + "CSB_" + (leaky ? "leaky" : "fixed") + "_" + Guid.NewGuid().ToString("N").Substring(0, 8)); + Directory.CreateDirectory(tmpFolder); + } + + // 6. サンプラースレッド(~200ms ごとに記録・peak 更新・ceiling 監視) + var swatch = System.Diagnostics.Stopwatch.StartNew(); + var sampler = new Thread(() => { + while (!pipelineDone.IsSet) { + var (priv, ws) = MemBenchSupport.Sample(); + lock (peakLock) { + if (priv > r.PeakPriv) r.PeakPriv = priv; + if (ws > r.PeakWs) r.PeakWs = ws; + } + allSamples.Enqueue(new MemSample { + ElapsedSec = swatch.Elapsed.TotalSeconds, + Priv = priv, Ws = ws, Variant = label + }); + if (priv >= ceiling) { + Interlocked.Exchange(ref abortFlag, 1); + } + pipelineDone.Wait(200); + } + }); + sampler.IsBackground = true; + sampler.Start(); + + // 2. BlockingCollection 生成 + var shots = new BlockingCollection(); + + // 3. consumer スレッド起動(SaveThread 相当) + var consumer = new Thread(() => { + int saveCount = 0; + foreach (var img in shots.GetConsumingEnumerable()) { + // SaveImages 相当: RGB を書き出し → 各 channel について変換・解放. + if (writeFiles) { + Cv2.ImWrite(Path.Combine(tmpFolder, $"rgb_{saveCount + 1:0000}.png"), img); + } + + // 本番 SaveImages は「同一 img を複数チャンネルで変換」する. + // ただし ConvertImage は src を in-place で CV_64FC3 に書き換える副作用があり + // (優先度4 の既知問題),同一 img を使い回すと2チャンネル目以降が壊れる. + // 計測目的上,各 channel 変換の直前に img のコピーを作りそれを変換に渡す. + // フレーム img 自体(shots に積まれたもの)は Dispose せず保持し続ける + // (優先度5 相当の未解放). + // - leaky 版: コピーは using せず放置(中間 Mat と共にリーク). + // - fixed 版: コピーは using で解放(修正後の挙動). + foreach (var ch in channels) { + var conv = convMats[ch]; + if (leaky) { + // 修正前: img のコピー(src)も中間 Mat も Dispose しない. + var src = img.Clone(); + var converted = MemBenchSupport.ConvertImageLeaky(corrector, src, conv); + if (writeFiles) { + Cv2.ImWrite(Path.Combine(tmpFolder, $"srgb_{ch:00}_{saveCount + 1:0000}.png"), converted); + } + // src / converted / 中間 Mat は放置(=リーク) + } else { + // 修正後: img のコピーと変換結果を using で解放. + using (var src = img.Clone()) + using (var converted = corrector.ConvertImage(src, conv)) { + if (writeFiles) { + Cv2.ImWrite(Path.Combine(tmpFolder, $"srgb_{ch:00}_{saveCount + 1:0000}.png"), converted); + } + } + } + } + + saveCount++; + Interlocked.Exchange(ref framesProcessed, saveCount); + // img は Dispose しない(本番同様・フレームバッファとして保持) + } + }); + consumer.Start(); + + // 4. producer(RunShotLoop 相当・メインスレッド) + try { + for (int i = 0; i < frames; i++) { + if (Interlocked.Read(ref abortFlag) != 0) { + TestContext.WriteLine($" ★ 上限 {MemBenchSupport.GB(ceiling):F1} GB に到達.frame {i} で投入を停止(リーク蓄積の限界)."); + r.Aborted = true; + break; // 以降 Add しない + } + shots.Add(MakeSyntheticFrame(width, height)); + if (interval > 0) Thread.Sleep(interval); + } + } finally { + shots.CompleteAdding(); + } + + // 5. consumer を Join + consumer.Join(); + + // pipeline 終了でサンプラー停止 + pipelineDone.Set(); + sampler.Join(); + + // 終了後の最終サンプル + var (finalPriv, finalWs) = MemBenchSupport.Sample(); + lock (peakLock) { + if (finalPriv > r.PeakPriv) r.PeakPriv = finalPriv; + if (finalWs > r.PeakWs) r.PeakWs = finalWs; + } + r.FramesProcessed = (int)Interlocked.Read(ref framesProcessed); + + TestContext.WriteLine($"結果: frames {r.FramesProcessed} / peak Private {MemBenchSupport.GB(r.PeakPriv):F2} GB / " + + $"peak WS {MemBenchSupport.GB(r.PeakWs):F2} GB / 増分 {MemBenchSupport.GB(r.PeakPriv - r.BasePriv):F2} GB" + + (r.Aborted ? " (打ち切り)" : "")); + TestContext.WriteLine(""); + + shots.Dispose(); + pipelineDone.Dispose(); + + // 一時フォルダ後始末 + if (tmpFolder != null) { + try { Directory.Delete(tmpFolder, true); } catch (IOException ex) { + TestContext.WriteLine($" (一時フォルダ削除に失敗: {ex.Message})"); + } + } + + return r; + } + + /// 時系列サンプルを CSV(UTF-8)に出力する. + static string WriteCsv(ConcurrentQueue samples) { + var dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "ContinuousShootBench_Results"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"continuous_shoot_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + + var sb = new StringBuilder(); + sb.AppendLine("elapsed_sec,private_MB,ws_MB,variant"); + foreach (var s in samples) { + sb.AppendLine(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0:F3},{1:F1},{2:F1},{3}", + s.ElapsedSec, MemBenchSupport.MB(s.Priv), MemBenchSupport.MB(s.Ws), s.Variant)); + } + File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + return path; + } + + } +} diff --git a/TIASshot.Tests/ConvertImageMemoryBenchmark.cs b/TIASshot.Tests/ConvertImageMemoryBenchmark.cs new file mode 100644 index 0000000..6a87d35 --- /dev/null +++ b/TIASshot.Tests/ConvertImageMemoryBenchmark.cs @@ -0,0 +1,157 @@ +using System; +using NUnit.Framework; +using OpenCvSharp; + +namespace TIASshot.Tests { + /// + /// PLAN_02 優先度2(ConvertImage の中間 Mat 未 Dispose)修正の効果検証ベンチマーク. + /// + /// 修正前(リーク版)と修正後(using 版)の ConvertImage を同条件で繰り返し呼び出し, + /// プロセスのメモリ使用量(PrivateMemorySize64 / WorkingSet64)を比較する. + /// + /// 注意: + /// - OpenCvSharp の Mat はネイティブメモリを持つ.未 Dispose の Mat は GC+ファイナライザが + /// 走るまで解放されない.連続撮影の本番フローでは GC 前に確保が積み上がり OOM クラッシュする. + /// 本ベンチはループ中に GC を呼ばないことでこの機序を再現する. + /// - 通常のテスト実行では走らない([Explicit] + Category("Benchmark")). + /// 実行は vstest の TestCaseFilter で明示指定する(README 末尾の実行コマンド参照). + /// + /// 環境変数でスケール調整可: + /// BENCH_WIDTH 画像幅 (既定 2440) + /// BENCH_HEIGHT 画像高 (既定 1840) + /// BENCH_CHANNEL 拡張チャネル数 (既定 17) + /// BENCH_FRAMES 繰り返し回数 (既定 40) + /// BENCH_CEILING_GB 打ち切り上限GB (既定 10) ← リーク版がここに達したら「限界」として停止 + /// + /// 計測ユーティリティ(Sample / ReclaimNative / GB / PrintRow / EnvInt / EnvDouble)は + /// MemBenchSupport に集約. + /// + [TestFixture] + [Explicit("メモリ計測ベンチマーク.フルスケールで大量のメモリを確保するため明示実行のみ.")] + [Category("Benchmark")] + public class ConvertImageMemoryBenchmark { + + [Test] + public void Compare_LeakyVsFixed_MemoryUsage() { + int width = MemBenchSupport.EnvInt("BENCH_WIDTH", 2440); + int height = MemBenchSupport.EnvInt("BENCH_HEIGHT", 1840); + int channel = MemBenchSupport.EnvInt("BENCH_CHANNEL", 17); + int frames = MemBenchSupport.EnvInt("BENCH_FRAMES", 40); + long ceiling = (long)(MemBenchSupport.EnvDouble("BENCH_CEILING_GB", 10.0) * 1024 * 1024 * 1024); + + var corrector = new ColorCorrector(null, null, new int[] { channel }); + + // 変換行列(channel x 3).値は任意(メモリ挙動に影響しない). + using (var conv = new Mat(channel, 3, MatType.CV_64FC1, new Scalar(0.01))) { + + TestContext.WriteLine("=== ConvertImage メモリ計測ベンチマーク(PLAN_02 優先度2) ==="); + TestContext.WriteLine($"画像: {width}x{height} CV_8UC3 / チャネル: {channel} / 繰り返し: {frames} 回"); + TestContext.WriteLine($"打ち切り上限: {MemBenchSupport.GB(ceiling):F1} GB (PrivateMemory)"); + TestContext.WriteLine(""); + + // --- 修正後(using 版)を先にクリーンな状態で計測 --- + var fixedResult = RunVariant( + "修正後(using 版)", + (src) => corrector.ConvertImage(src, conv), + width, height, frames, ceiling); + + // リーク版の前にネイティブメモリを回収して baseline を揃える + MemBenchSupport.ReclaimNative(); + + // --- 修正前(リーク版)を計測 --- + var leakyResult = RunVariant( + "修正前(リーク版)", + (src) => MemBenchSupport.ConvertImageLeaky(corrector, src, conv), + width, height, frames, ceiling); + + // 放置したネイティブメモリを後始末(後続テストへの影響を防ぐ) + MemBenchSupport.ReclaimNative(); + + // --- 比較サマリ --- + TestContext.WriteLine(""); + TestContext.WriteLine("================ 比較サマリ ================"); + MemBenchSupport.PrintRow("項目", "修正前(リーク版)", "修正後(using 版)"); + MemBenchSupport.PrintRow("完了/打ち切り", + leakyResult.Aborted ? $"打ち切り(iter {leakyResult.Iterations})" : $"完走({leakyResult.Iterations})", + fixedResult.Aborted ? $"打ち切り(iter {fixedResult.Iterations})" : $"完走({fixedResult.Iterations})"); + MemBenchSupport.PrintRow("Private baseline", + $"{MemBenchSupport.GB(leakyResult.BasePriv):F2} GB", + $"{MemBenchSupport.GB(fixedResult.BasePriv):F2} GB"); + MemBenchSupport.PrintRow("Private peak", + $"{MemBenchSupport.GB(leakyResult.PeakPriv):F2} GB", + $"{MemBenchSupport.GB(fixedResult.PeakPriv):F2} GB"); + MemBenchSupport.PrintRow("Private 増分(peak-base)", + $"{MemBenchSupport.GB(leakyResult.PeakPriv - leakyResult.BasePriv):F2} GB", + $"{MemBenchSupport.GB(fixedResult.PeakPriv - fixedResult.BasePriv):F2} GB"); + MemBenchSupport.PrintRow("WorkingSet peak", + $"{MemBenchSupport.GB(leakyResult.PeakWs):F2} GB", + $"{MemBenchSupport.GB(fixedResult.PeakWs):F2} GB"); + MemBenchSupport.PrintRow("1回あたり平均増分", + $"{(leakyResult.Iterations > 0 ? MemBenchSupport.GB(leakyResult.PeakPriv - leakyResult.BasePriv) / leakyResult.Iterations * 1024 : 0):F0} MB", + $"{(fixedResult.Iterations > 0 ? MemBenchSupport.GB(fixedResult.PeakPriv - fixedResult.BasePriv) / fixedResult.Iterations * 1024 : 0):F0} MB"); + TestContext.WriteLine("============================================"); + + var leakyGrowth = leakyResult.PeakPriv - leakyResult.BasePriv; + var fixedGrowth = fixedResult.PeakPriv - fixedResult.BasePriv; + TestContext.WriteLine(""); + TestContext.WriteLine( + $"判定: 修正後の増分({MemBenchSupport.GB(fixedGrowth):F2} GB) は 修正前の増分({MemBenchSupport.GB(leakyGrowth):F2} GB) の " + + $"{(leakyGrowth > 0 ? (double)fixedGrowth / leakyGrowth * 100 : 0):F1}% に抑制された."); + + // この閾値判定はあくまで「修正が効いていること」の目安(緩め). + Assert.That(fixedGrowth, Is.LessThan(leakyGrowth), + "修正後の方がメモリ増分が小さいこと(修正が効いている)"); + } + } + + class VariantResult { + public long BasePriv, PeakPriv, FinalPriv, PeakWs; + public int Iterations; + public bool Aborted; + } + + /// + /// 1 つの ConvertImage バリアントを frames 回繰り返し,メモリ推移を計測する. + /// src は in-place 書き換え(CV_64FC3)副作用があるため毎回新規生成して using で破棄する. + /// 戻り値(convImg8)は呼び出し側相当で破棄する(中間 Mat のリークだけを切り出すため). + /// + VariantResult RunVariant(string label, Func convert, int width, int height, int frames, long ceiling) { + MemBenchSupport.ReclaimNative(); + var (basePriv, baseWs) = MemBenchSupport.Sample(); + var r = new VariantResult { BasePriv = basePriv, PeakPriv = basePriv, PeakWs = baseWs }; + + TestContext.WriteLine($"--- {label} ---"); + TestContext.WriteLine($"baseline: Private {MemBenchSupport.GB(basePriv):F2} GB / WS {MemBenchSupport.GB(baseWs):F2} GB"); + + int i = 0; + for (; i < frames; i++) { + using (var src = new Mat(height, width, MatType.CV_8UC3, new Scalar(50, 100, 150))) + using (var dst = convert(src)) { + // dst は using で破棄(呼び出し側 SaveImages の using 相当) + } + + var (priv, ws) = MemBenchSupport.Sample(); + if (priv > r.PeakPriv) r.PeakPriv = priv; + if (ws > r.PeakWs) r.PeakWs = ws; + + if ((i + 1) % 5 == 0 || i == 0) { + TestContext.WriteLine($" iter {i + 1,3}: Private {MemBenchSupport.GB(priv):F2} GB / WS {MemBenchSupport.GB(ws):F2} GB"); + } + + if (priv >= ceiling) { + TestContext.WriteLine($" ★ 上限 {MemBenchSupport.GB(ceiling):F1} GB に到達.iter {i + 1} で打ち切り(リーク蓄積の限界)."); + r.Aborted = true; + i++; + break; + } + } + + var (finalPriv, _) = MemBenchSupport.Sample(); + r.FinalPriv = finalPriv; + r.Iterations = i; + TestContext.WriteLine($"結果: iter {i} / peak Private {MemBenchSupport.GB(r.PeakPriv):F2} GB / peak WS {MemBenchSupport.GB(r.PeakWs):F2} GB / 増分 {MemBenchSupport.GB(r.PeakPriv - r.BasePriv):F2} GB"); + TestContext.WriteLine(""); + return r; + } + } +} diff --git a/TIASshot.Tests/MemBenchSupport.cs b/TIASshot.Tests/MemBenchSupport.cs new file mode 100644 index 0000000..651ef21 --- /dev/null +++ b/TIASshot.Tests/MemBenchSupport.cs @@ -0,0 +1,80 @@ +using System; +using System.Diagnostics; +using NUnit.Framework; +using OpenCvSharp; + +namespace TIASshot.Tests { + /// + /// メモリ計測ベンチ(ConvertImageMemoryBenchmark / ContinuousShootMemoryBenchmark)が + /// 共有する補助ロジック. + /// + /// - リーク版 ConvertImage の再現(ConvertImageLeaky) + /// - 計測ユーティリティ(Sample / ReclaimNative / GB / MB / PrintRow) + /// - 環境変数パースヘルパー(EnvInt / EnvDouble) + /// + /// 本番の ColorCorrector.ConvertImage(Mat,Mat) は通常ビルドでは修正後(中間 Mat を + /// using 解放)であり,リーク挙動はこのクラスでのみ再現する. + /// + internal static class MemBenchSupport { + + // ---- リーク版 ConvertImage ----------------------------------------------- + + /// + /// 修正前(リーク版)の ConvertImage を忠実に再現したもの. + /// 中間 Mat(flatten / extended / converted / convertedImage)を Dispose しない. + /// (git 0003cc2^ の ColorCorrector.ConvertImage(Mat,Mat) と等価) + /// + /// 本番コードは無改造のため,リーク再現に必要な internal メソッド(ExtendMat)と + /// 行列積のみを ColorCorrector インスタンス経由で利用する. + /// + internal static Mat ConvertImageLeaky(ColorCorrector corrector, Mat src, Mat conv) { + if (src.Type() != MatType.CV_64FC3) { + src.ConvertTo(src, MatType.CV_64FC3); + } + var flatten = src.Reshape(3, src.Height * src.Width); + var extended = corrector.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 は放置(=リーク) + } + + // ---- 計測ユーティリティ ------------------------------------------------- + + /// 現在のプロセスのメモリ使用量をサンプリングする. + internal static (long priv, long ws) Sample() { + using (var p = Process.GetCurrentProcess()) { + p.Refresh(); + return (p.PrivateMemorySize64, p.WorkingSet64); + } + } + + /// 未参照 Mat のネイティブメモリを GC+ファイナライザで回収する. + internal static void ReclaimNative() { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + internal static double GB(long bytes) => bytes / (1024.0 * 1024.0 * 1024.0); + internal static double MB(long bytes) => bytes / (1024.0 * 1024.0); + + internal static void PrintRow(string a, string b, string c) { + TestContext.WriteLine($"{a,-24} | {b,-22} | {c,-22}"); + } + + // ---- 環境変数パースヘルパー --------------------------------------------- + + internal static int EnvInt(string name, int fallback) { + var v = Environment.GetEnvironmentVariable(name); + return int.TryParse(v, out var n) ? n : fallback; + } + + internal static double EnvDouble(string name, double fallback) { + var v = Environment.GetEnvironmentVariable(name); + return double.TryParse(v, out var n) ? n : fallback; + } + } +} diff --git a/TIASshot.Tests/MemBenchSupportTests.cs b/TIASshot.Tests/MemBenchSupportTests.cs new file mode 100644 index 0000000..ff12edc --- /dev/null +++ b/TIASshot.Tests/MemBenchSupportTests.cs @@ -0,0 +1,69 @@ +using NUnit.Framework; +using OpenCvSharp; + +namespace TIASshot.Tests { + /// + /// MemBenchSupport のテスト + /// + [TestFixture] + public class MemBenchSupportTests { + + // ---- ヘルパー -------------------------------------------------------- + + /// channels=4 の ColorCorrector を生成する + private static ColorCorrector MakeCorrectorWith4Channels() + => new ColorCorrector(null, null, new int[] { 4 }); + + // ---- ConvertImageLeaky テスト ---------------------------------------- + + /// + /// ConvertImageLeaky: 2x2 の CV_8UC3 入力と 4x3 の conv 行列を与えたとき + /// 出力が CV_8UC3 かつ入力と同じ高さ×幅になること。 + /// これは MemBenchSupport.ConvertImageLeaky が ColorCorrector.ConvertImage と + /// 同じ出力形式を持つことの不変量確認(ヘルパー抽出の正しさの担保)。 + /// + [Test] + public void ConvertImageLeaky_2x2Input_OutputIsCV8UC3WithSameSize() { + // Arrange: 2行2列 CV_8UC3(B=50, G=100, R=150) + using (var conv = new Mat(4, 3, MatType.CV_64FC1, new Scalar(0.01))) { + var corrector = MakeCorrectorWith4Channels(); + + // src は ConvertImageLeaky の in-place 書き換えで CV_64FC3 に変化するため、 + // using で囲みつつ返り値(convImg8)も Dispose する + using (var src = new Mat(2, 2, MatType.CV_8UC3, new Scalar(50, 100, 150))) + using (var dst = MemBenchSupport.ConvertImageLeaky(corrector, src, conv)) { + // Assert: 型・サイズ + Assert.AreEqual(MatType.CV_8UC3, dst.Type(), "出力型は CV_8UC3"); + Assert.AreEqual(2, dst.Rows, "出力行数は入力と同じ(2)"); + Assert.AreEqual(2, dst.Cols, "出力列数は入力と同じ(2)"); + } + } + } + + /// + /// ConvertImageLeaky: conv が全ゼロのとき出力全ピクセルが 0 になること。 + /// ConvertImageLeaky の変換処理が conv 行列の値に依存して決まることの確認。 + /// (ColorCorrector.ConvertImage の同等テストと対称的な確認) + /// + [Test] + public void ConvertImageLeaky_ZeroConv_AllOutputPixelsAreZero() { + // Arrange: 2x2 CV_8UC3(B=50, G=100, R=150)、conv は全ゼロ + using (var conv = new Mat(4, 3, MatType.CV_64FC1, new Scalar(0.0))) { + var corrector = MakeCorrectorWith4Channels(); + + using (var src = new Mat(2, 2, MatType.CV_8UC3, new Scalar(50, 100, 150))) + using (var dst = MemBenchSupport.ConvertImageLeaky(corrector, src, conv)) { + // Assert: 全ピクセルが 0 + for (int row = 0; row < dst.Rows; row++) { + for (int col = 0; col < dst.Cols; col++) { + var pixel = dst.At(row, col); + Assert.AreEqual(0, pixel.Item0, $"row={row}, col={col}: B は 0"); + Assert.AreEqual(0, pixel.Item1, $"row={row}, col={col}: G は 0"); + Assert.AreEqual(0, pixel.Item2, $"row={row}, col={col}: R は 0"); + } + } + } + } + } + } +} diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index ba327ef..c3cb763 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -59,6 +59,10 @@ + + + + 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..9d3e2f3 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,22 @@ | 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)には含めない. + +### 定量比較(自動・数値) + +- 2 種類の NUnit ベンチ(いずれも `[Explicit]`・通常テストでは走らず明示指定で実行)を用意してある.修正前(リーク版を忠実に再現)と修正後(実 `ConvertImage`)を同条件で計測し,`Process.PrivateMemorySize64` / `WorkingSet64` の baseline・peak・増分を比較する(カメラ実機不要・ヘッドレス): + - `TIASshot.Tests/ConvertImageMemoryBenchmark.cs`: `ConvertImage` 単体を反復する隔離計測. + - `TIASshot.Tests/ContinuousShootMemoryBenchmark.cs`: 連続撮影フロー(`BlockingCollection` の生産者-消費者・1 フレーム複数チャンネル変換・フレームバッファ)を**合成フレームで構造再現**したフロー計測(カメラ取得部のみ差し替え.本番メソッドそのものの呼び出しではない).本番に近い「フレーム単位の増え方」を測れる. + - リーク再現ロジックと計測ユーティリティは `TIASshot.Tests/MemBenchSupport.cs` に共有化. +- Mat はネイティブメモリのため `GC.GetTotalMemory` では捕捉できない.計測ループ中は GC を呼ばない(本番のリーク機序を再現するため). +- 代表的な計測結果は `docs/06_TEST/TEST_01_メモリ修正効果計測.md` に記録. + +### デモ(実アプリのメモリをライブ可視化) + +- コンパイル記号 **`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/06_TEST/TEST_01_\343\203\241\343\203\242\343\203\252\344\277\256\346\255\243\345\212\271\346\236\234\350\250\210\346\270\254.md" "b/docs/06_TEST/TEST_01_\343\203\241\343\203\242\343\203\252\344\277\256\346\255\243\345\212\271\346\236\234\350\250\210\346\270\254.md" new file mode 100644 index 0000000..dba8fe8 --- /dev/null +++ "b/docs/06_TEST/TEST_01_\343\203\241\343\203\242\343\203\252\344\277\256\346\255\243\345\212\271\346\236\234\350\250\210\346\270\254.md" @@ -0,0 +1,80 @@ +# メモリ修正効果の計測結果(PLAN_02 優先度2) (Memory Fix Effect Measurement) + +: 2026-06-23 +対象: PLAN_02 優先度2「`ColorCorrector.ConvertImage` の中間 Mat 未 Dispose(OOM リーク)」修正 +計測者: 自動ベンチ(NUnit、ヘッドレス実行・カメラ実機不要) + +## 目的 (Purpose) + +修正前(中間 Mat を解放しない=旧実装)と修正後(入れ子 `using` で解放)で、連続処理時のメモリ使用量の「増え方」がどれだけ変わるかを同一条件で定量比較する。 + +## 方法 (Method) + +- ベンチ: `TIASshot.Tests/ConvertImageMemoryBenchmark.cs`(`[Explicit]`)。 + - 修正後 = 実コード `ColorCorrector.ConvertImage`(using 版)。 + - 修正前 = 旧実装を忠実に再現(`flatten`/`extended`/`converted`/`convertedImage` を解放しない)。 + - 同一画像を N 回反復し、各反復後に `Process.PrivateMemorySize64` / `WorkingSet64` をサンプリング。 +- 指標: Mat はネイティブメモリのため `GC.GetTotalMemory` では捕捉できない → `PrivateMemorySize64`(コミット量)を主指標とする。 +- 計測ループ中は GC を強制しない(本番のリーク機序を再現するため)。物理メモリ枯渇を防ぐ打ち切り上限ガード付き。 +- 実行環境: x64 / .NET Framework 4.8 / 物理メモリ 16 GB。 + +## 結果 (Results) + +### 条件A: IScam フルスケール(2440×1840・17ch・40 回) + +| 指標 | 修正前(リーク) | 修正後(using) | +| --- | --- | --- | +| Private baseline | 0.05 GB | 0.05 GB | +| Private peak | **2.83 GB** | **0.62 GB** | +| 増分 (peak−base) | 2.78 GB | 0.57 GB | +| 1 回あたり平均増分 | **71 MB/回** | **15 MB/回** | +| 推移 | 0.82→1.49→2.16→2.83 GB と階段状に上昇 | 全 40 回 0.62 GB で一定 | + +→ 修正後の増分は修正前の **約 20%**。 + +### 条件B: 小サイズ・多反復(900×700・17ch・60 回) + +| 指標 | 修正前(リーク) | 修正後(using) | +| --- | --- | --- | +| Private peak | **0.53 GB** | **0.13 GB** | +| 増分 (peak−base) | 0.48 GB | 0.08 GB | +| 1 回あたり平均増分 | **8 MB/回** | **1 MB/回** | +| 推移 | 反復に伴い増加(0.16→0.44→0.53 GB) | 全 60 回 0.13 GB で一定 | + +→ 修正後の増分は修正前の **約 17%**。 + +### 条件C: 連続撮影フロー再現(フルスケール・2440×1840・channels 4/10/17・8 フレーム) + +`ContinuousShootMemoryBenchmark`(`BlockingCollection` の生産者-消費者・1 フレーム複数チャンネル変換・フレームバッファを合成フレームで構造再現)での計測。単体反復でなく「実機の連続撮影に近いフロー」での結果。 + +| 指標 | 修正前(リーク) | 修正後(using) | +| --- | --- | --- | +| Private peak | **2.60 GB** | **0.92 GB** | +| 増分 (peak−base) | 2.55 GB | 0.87 GB | +| **1 フレームあたり増分** | **326 MB/frame** | **112 MB/frame** | +| WorkingSet peak | 2.47 GB | 0.92 GB | + +→ 修正後の増分は修正前の **約 34%**。修正前は 1 フレームごとに約 326 MB 積み上がる(フレーム数を増やせば打ち切り上限=OOM へ)。修正後は約 0.9 GB で頭打ち(処理中の一時ピークで蓄積なし)。修正後の per-frame 112 MB は主に「保持中フレーム+処理中の一時メモリ」で,反復しても天井は一定. + +## 解釈 (Interpretation) + +- **修正後は反復しても増えない**(peak の天井が一定=蓄積ゼロ)。各呼び出しで中間 Mat が確定的に解放される。 +- **修正前は反復に伴いメモリが増える**(1 回あたり数十 MB〜)。本番は IScam 17ch × 複数フレームを `SaveThread` で高速に回すため、ファイナライザの回収が追いつかず確保が先行 → OOM クラッシュに至る(PLAN_02 の症状)。 +- 修正前のグラフが滑らかでなく**階段状/のこぎり波**になるのは、OpenCvSharp が GC にメモリ圧を通知し、未解放でも GC+ファイナライザが周期的にネイティブメモリを一掃するため。見るべきは振れ幅でなく**ピーク包絡線のトレンド**(修正後=水平、修正前=上昇)。 + +## 限界・補足 (Limitations) + +- 条件A/B は `ConvertImage` 単体の隔離計測。条件C は連続撮影フローを**構造再現**したもので、生産者-消費者スレッディング・多チャンネル・フレームバッファを含むが、**カメラ取得部のみ合成フレームに差し替えた再現**であり本番メソッド(`Shot`/`RunShotLoop`/`SaveThread`)そのものの呼び出しではない。実フレームレート・実カメラ・ファイル I/O(既定 OFF)までは含まない。実使用そのものの挙動は、`MEMMONITOR` ビルドのメモリ観測ウィンドウ+実機撮影の録画で別途確認する(PLAN_02「効果検証(メモリ修正)の方法」参照)。 +- 優先度5(Clear 時の未 Dispose)は未修正のため本対比の対象外(修正前/後どちらの変種にも同じだけ含まれ、差分には現れない)。 + +## 再現方法 (Reproduction) + +```powershell +# テストプロジェクトを Debug|x64 でビルド後 +$vstest = "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\Extensions\TestPlatform\vstest.console.exe" +$dll = "TIASshot.Tests\bin\x64\Debug\TIASshot.Tests.dll" +$env:BENCH_WIDTH="2440"; $env:BENCH_HEIGHT="1840"; $env:BENCH_CHANNEL="17"; $env:BENCH_FRAMES="40"; $env:BENCH_CEILING_GB="10" +& $vstest $dll /Platform:x64 /Framework:".NETFramework,Version=v4.8" /Tests:Compare_LeakyVsFixed_MemoryUsage /logger:"console;verbosity=normal" +``` + +※ スケールは環境変数 `BENCH_WIDTH/HEIGHT/CHANNEL/FRAMES/CEILING_GB` で調整可。 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 6384396..a13e6ab 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -65,3 +65,14 @@ - 動機: 「デバッグビルドか」と「白板撮影を含めるか」は別関心事なのに 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のみ)。グラフのトレンド可視化(包絡線・移動平均等)は要望次第で追加余地。 + +- [x] **連続撮影フロー再現のメモリ計測ベンチ+計測結果レポート** (2026-06-23): 既存の単体ループベンチ(`ConvertImage` 隔離)では本番の連続撮影フローを再現できていなかったため、カメラ取得部だけを合成フレームに差し替えて本番フローを構造再現するベンチ(`ContinuousShootMemoryBenchmark`)を追加。生産者-消費者スレッディング・1 フレーム複数チャンネル変換・フレームバッファを含む。修正前/後の計測結果を `docs/06_TEST/TEST_01_メモリ修正効果計測.md` にまとめた(カメラ実機不要・ヘッドレスで Claude 側が実行可能)。 + - 設計判断: 本番 `CameraBase.RunShotLoop`/`SaveThread`/`SaveImages` を直接ヘッドレス実行するには Form1・Config・TCC・校正で埋まる `_convRGB2SRGB` 依存が重すぎる → 本番コード無改造のまま「フローの構造再現」を選択(本番メソッドそのものの呼び出しではない旨を明記)。リーク再現と計測ユーティリティは `MemBenchSupport` に共有化し既存ベンチと統一。 + - 結果の要点: フロー再現フルスケールで修正後増分は修正前の約 34%(per-frame 112 vs 326 MB)。単体隔離(約 17〜20%)と数値が違うのは、フロー版がフレーム保持+多チャンネル+並行処理の一時メモリを含むため。 + - 役割分担の整理: メモリの「数値」はベンチ(Claude 側・再現性高)で、実使用の「動画デモ」は MEMMONITOR +実機録画(人間)で、と用途を分けた。