diff --git a/CLAUDE.md b/CLAUDE.md index a77eb48..e375fcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 メモリ修正の効果検証用にメモリ観測ウィンドウ(MEMMONITOR)とリーク再現モード(LEAKY_REPRO)を統合(実アプリのメモリをライブ可視化・対比録画用) +最新: 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 index 4ed4231..6a87d35 100644 --- a/TIASshot.Tests/ConvertImageMemoryBenchmark.cs +++ b/TIASshot.Tests/ConvertImageMemoryBenchmark.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using NUnit.Framework; using OpenCvSharp; @@ -23,65 +22,22 @@ /// 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 { - static int EnvInt(string name, int fallback) { - var v = Environment.GetEnvironmentVariable(name); - return int.TryParse(v, out var n) ? n : fallback; - } - - static double EnvDouble(string name, double fallback) { - var v = Environment.GetEnvironmentVariable(name); - return double.TryParse(v, out var n) ? n : fallback; - } - - /// 現在のプロセスのメモリ使用量をサンプリングする. - static (long priv, long ws) Sample() { - using (var p = Process.GetCurrentProcess()) { - p.Refresh(); - return (p.PrivateMemorySize64, p.WorkingSet64); - } - } - - static double GB(long bytes) => bytes / (1024.0 * 1024.0 * 1024.0); - - /// 未参照 Mat のネイティブメモリを GC+ファイナライザで回収する. - static void ReclaimNative() { - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - } - - /// - /// 修正前(リーク版)の ConvertImage を忠実に再現したもの. - /// 中間 Mat(flatten / extended / converted / convertedImage)を Dispose しない. - /// (git 0003cc2^ の ColorCorrector.ConvertImage(Mat,Mat) と等価) - /// - 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 は放置(=リーク) - } - [Test] public void Compare_LeakyVsFixed_MemoryUsage() { - int width = EnvInt("BENCH_WIDTH", 2440); - int height = EnvInt("BENCH_HEIGHT", 1840); - int channel = EnvInt("BENCH_CHANNEL", 17); - int frames = EnvInt("BENCH_FRAMES", 40); - long ceiling = (long)(EnvDouble("BENCH_CEILING_GB", 10.0) * 1024 * 1024 * 1024); + 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 }); @@ -90,7 +46,7 @@ TestContext.WriteLine("=== ConvertImage メモリ計測ベンチマーク(PLAN_02 優先度2) ==="); TestContext.WriteLine($"画像: {width}x{height} CV_8UC3 / チャネル: {channel} / 繰り返し: {frames} 回"); - TestContext.WriteLine($"打ち切り上限: {GB(ceiling):F1} GB (PrivateMemory)"); + TestContext.WriteLine($"打ち切り上限: {MemBenchSupport.GB(ceiling):F1} GB (PrivateMemory)"); TestContext.WriteLine(""); // --- 修正後(using 版)を先にクリーンな状態で計測 --- @@ -100,38 +56,46 @@ width, height, frames, ceiling); // リーク版の前にネイティブメモリを回収して baseline を揃える - ReclaimNative(); + MemBenchSupport.ReclaimNative(); // --- 修正前(リーク版)を計測 --- var leakyResult = RunVariant( "修正前(リーク版)", - (src) => ConvertImageLeaky(corrector, src, conv), + (src) => MemBenchSupport.ConvertImageLeaky(corrector, src, conv), width, height, frames, ceiling); // 放置したネイティブメモリを後始末(後続テストへの影響を防ぐ) - ReclaimNative(); + MemBenchSupport.ReclaimNative(); // --- 比較サマリ --- TestContext.WriteLine(""); TestContext.WriteLine("================ 比較サマリ ================"); - PrintRow("項目", "修正前(リーク版)", "修正後(using 版)"); - PrintRow("完了/打ち切り", + MemBenchSupport.PrintRow("項目", "修正前(リーク版)", "修正後(using 版)"); + MemBenchSupport.PrintRow("完了/打ち切り", leakyResult.Aborted ? $"打ち切り(iter {leakyResult.Iterations})" : $"完走({leakyResult.Iterations})", fixedResult.Aborted ? $"打ち切り(iter {fixedResult.Iterations})" : $"完走({fixedResult.Iterations})"); - PrintRow("Private baseline", $"{GB(leakyResult.BasePriv):F2} GB", $"{GB(fixedResult.BasePriv):F2} GB"); - PrintRow("Private peak", $"{GB(leakyResult.PeakPriv):F2} GB", $"{GB(fixedResult.PeakPriv):F2} GB"); - PrintRow("Private 増分(peak-base)", $"{GB(leakyResult.PeakPriv - leakyResult.BasePriv):F2} GB", $"{GB(fixedResult.PeakPriv - fixedResult.BasePriv):F2} GB"); - PrintRow("WorkingSet peak", $"{GB(leakyResult.PeakWs):F2} GB", $"{GB(fixedResult.PeakWs):F2} GB"); - PrintRow("1回あたり平均増分", - $"{(leakyResult.Iterations > 0 ? GB(leakyResult.PeakPriv - leakyResult.BasePriv) / leakyResult.Iterations * 1024 : 0):F0} MB", - $"{(fixedResult.Iterations > 0 ? GB(fixedResult.PeakPriv - fixedResult.BasePriv) / fixedResult.Iterations * 1024 : 0):F0} MB"); + 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( - $"判定: 修正後の増分({GB(fixedGrowth):F2} GB) は 修正前の増分({GB(leakyGrowth):F2} GB) の " + $"判定: 修正後の増分({MemBenchSupport.GB(fixedGrowth):F2} GB) は 修正前の増分({MemBenchSupport.GB(leakyGrowth):F2} GB) の " + $"{(leakyGrowth > 0 ? (double)fixedGrowth / leakyGrowth * 100 : 0):F1}% に抑制された."); // この閾値判定はあくまで「修正が効いていること」の目安(緩め). @@ -152,12 +116,12 @@ /// 戻り値(convImg8)は呼び出し側相当で破棄する(中間 Mat のリークだけを切り出すため). /// VariantResult RunVariant(string label, Func convert, int width, int height, int frames, long ceiling) { - ReclaimNative(); - var (basePriv, baseWs) = Sample(); + MemBenchSupport.ReclaimNative(); + var (basePriv, baseWs) = MemBenchSupport.Sample(); var r = new VariantResult { BasePriv = basePriv, PeakPriv = basePriv, PeakWs = baseWs }; TestContext.WriteLine($"--- {label} ---"); - TestContext.WriteLine($"baseline: Private {GB(basePriv):F2} GB / WS {GB(baseWs):F2} GB"); + TestContext.WriteLine($"baseline: Private {MemBenchSupport.GB(basePriv):F2} GB / WS {MemBenchSupport.GB(baseWs):F2} GB"); int i = 0; for (; i < frames; i++) { @@ -166,32 +130,28 @@ // dst は using で破棄(呼び出し側 SaveImages の using 相当) } - var (priv, ws) = Sample(); + 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 {GB(priv):F2} GB / WS {GB(ws):F2} GB"); + TestContext.WriteLine($" iter {i + 1,3}: Private {MemBenchSupport.GB(priv):F2} GB / WS {MemBenchSupport.GB(ws):F2} GB"); } if (priv >= ceiling) { - TestContext.WriteLine($" ★ 上限 {GB(ceiling):F1} GB に到達.iter {i + 1} で打ち切り(リーク蓄積の限界)."); + TestContext.WriteLine($" ★ 上限 {MemBenchSupport.GB(ceiling):F1} GB に到達.iter {i + 1} で打ち切り(リーク蓄積の限界)."); r.Aborted = true; i++; break; } } - var (finalPriv, _) = Sample(); + var (finalPriv, _) = MemBenchSupport.Sample(); r.FinalPriv = finalPriv; r.Iterations = i; - TestContext.WriteLine($"結果: iter {i} / peak Private {GB(r.PeakPriv):F2} GB / peak WS {GB(r.PeakWs):F2} GB / 増分 {GB(r.PeakPriv - r.BasePriv):F2} GB"); + 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; } - - static void PrintRow(string a, string b, string c) { - TestContext.WriteLine($"{a,-24} | {b,-22} | {c,-22}"); - } } } 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 fb57e33..c3cb763 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -60,6 +60,9 @@ + + + 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 d67aebe..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" @@ -174,8 +174,12 @@ ### 定量比較(自動・数値) -- `TIASshot.Tests/ConvertImageMemoryBenchmark.cs`(NUnit・`[Explicit]`)で,修正前(リーク版を忠実に再現)と修正後(実 `ConvertImage`)を同条件で反復し,`Process.PrivateMemorySize64` / `WorkingSet64` の baseline・peak・増分を比較する.通常テスト実行では走らず,明示指定で実行する(実行コマンドはファイル冒頭コメント参照). +- 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` に記録. ### デモ(実アプリのメモリをライブ可視化) 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 e7bc2f8..a13e6ab 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -71,3 +71,8 @@ - 設計判断(失敗パターン圧縮): ① 独立実験アプリ案 → 「普通にアプリを使ったときの挙動」が見せられず破棄 ② 本体統合のライブ観測ウィンドウへ。出し分けは白板と同じビルド記号方式(`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 +実機録画(人間)で、と用途を分けた。