Newer
Older
TIASShot / TIASshot.Tests / ContinuousShootMemoryBenchmark.cs
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 {
    /// <summary>
    /// 「連続撮影フロー」を合成フレームで再現するメモリ計測ベンチ(PLAN_02 優先度2/5 検証).
    ///
    /// 【重要】これは本番メソッド(CameraBase.Shot/RunShotLoop/SaveThread/SaveImages)
    /// そのものの呼び出しではなく,<b>カメラ取得部分(CaptureFrame)のみを合成フレームに
    /// 差し替えた連続撮影フローの「構造再現」</b>である.本番コード
    /// (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)
    /// </summary>
    [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<int>();
            foreach (var part in v.Split(',')) {
                if (int.TryParse(part.Trim(), out var n)) list.Add(n);
            }
            return list.Count > 0 ? list.ToArray() : fallback;
        }

        // ---- 合成フレーム -----------------------------------------------------

        /// <summary>
        /// 合成フレーム(カメラ CaptureFrame の代替).本番の CV_8UC3 ROI フレーム相当.
        /// </summary>
        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<int, Mat>();
            foreach (var ch in channels) {
                convMats[ch] = new Mat(ch, 3, MatType.CV_64FC1, new Scalar(0.01));
            }

            // 時系列サンプルを全バリアント分まとめて CSV 出力する.
            var allSamples = new ConcurrentQueue<MemSample>();

            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();
            }
        }

        /// <summary>
        /// 1 つのバリアント(修正前/修正後)について連続撮影フローを構造再現し,メモリ推移を計測する.
        ///
        /// フロー再現(本番 Shot 相当):
        ///   1. ReclaimNative() → baseline 測定.
        ///   2. BlockingCollection 生成.
        ///   3. consumer スレッド起動(SaveThread 相当).
        ///   4. producer(RunShotLoop 相当・本メソッドのメインスレッド)でフレーム投入.
        ///   5. consumer を Join.
        ///   サンプラースレッドは別途並行して ~200ms ごとにメモリを記録し ceiling 監視する.
        /// </summary>
        VariantResult RunVariant(
            string label, bool leaky,
            ColorCorrector corrector, Dictionary<int, Mat> convMats, int[] channels,
            int width, int height, int frames, int interval, long ceiling, bool writeFiles,
            ConcurrentQueue<MemSample> 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<Mat>();

            // 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;
        }

        /// <summary>時系列サンプルを CSV(UTF-8)に出力する.</summary>
        static string WriteCsv(ConcurrentQueue<MemSample> 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;
        }

    }
}