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 +実機録画(人間)で、と用途を分けた。