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