using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenCvSharp;
namespace TIASshot {
/// <summary>
/// 1 フレーム分の飽和統計情報を保持する構造体(ピュアロジック).
/// </summary>
internal struct FrameStats {
public double SaturatedRatio; // 飽和画素率(値 >= 254 の割合)
public double P99; // ROI 輝度(グレースケール)の 99 パーセンタイル
public double MeanB;
public double MeanG;
public double MeanR;
public double MaxB;
public double MaxG;
public double MaxR;
}
/// <summary>
/// 飽和統計の算出と JSON サイドカー生成を担う純粋ユーティリティクラス(ハードウェア非依存).
/// <para>
/// GUIDE_08 ピュアロジック層に分類する:Mat を入力に統計を返す静的メソッド群,
/// および設定 dict → JSON 文字列変換メソッドを提供する.
/// [InternalsVisibleTo("TIASshot.Tests")] により TIASshot.Tests から呼び出せる.
/// </para>
/// </summary>
internal static class ImageStats {
// ---------------------------------------------------------------
// 統計算出
// ---------------------------------------------------------------
/// <summary>
/// ROI 適用済みの BGR Mat から 1 フレーム分の飽和統計を算出する(ピュアロジック).
/// </summary>
/// <param name="bgr">CV_8UC3 の BGR Mat(ROI 適用後)</param>
/// <returns>統計情報</returns>
internal static FrameStats Calc(Mat bgr) {
if (bgr == null) throw new ArgumentNullException("bgr");
if (bgr.Type() != MatType.CV_8UC3)
throw new ArgumentException("bgr must be CV_8UC3");
int total = bgr.Rows * bgr.Cols;
// チャンネル分離
Mat[] channels = Cv2.Split(bgr);
try {
Mat b = channels[0];
Mat g = channels[1];
Mat r = channels[2];
// --- チャンネル別 mean / max ---
Scalar meanB = Cv2.Mean(b);
Scalar meanG = Cv2.Mean(g);
Scalar meanR = Cv2.Mean(r);
double maxBVal, maxGVal, maxRVal;
Point dummy1, dummy2;
double dummyMin;
Cv2.MinMaxLoc(b, out dummyMin, out maxBVal, out dummy1, out dummy2);
Cv2.MinMaxLoc(g, out dummyMin, out maxGVal, out dummy1, out dummy2);
Cv2.MinMaxLoc(r, out dummyMin, out maxRVal, out dummy1, out dummy2);
// --- 輝度(グレースケール)への変換 ---
// OpenCV の BGR2GRAY: 0.114*B + 0.587*G + 0.299*R
using (Mat gray = new Mat()) {
Cv2.CvtColor(bgr, gray, ColorConversionCodes.BGR2GRAY);
// --- 飽和画素率(輝度 >= 254)---
using (Mat saturatedMask = new Mat()) {
Cv2.Threshold(gray, saturatedMask, 253.5, 1.0, ThresholdTypes.Binary);
int saturatedCount = (int)Cv2.Sum(saturatedMask).Val0;
double saturatedRatio = total > 0 ? (double)saturatedCount / total : 0.0;
// --- p99(輝度の 99 パーセンタイル)---
double p99 = CalcPercentile(gray, 99.0);
return new FrameStats {
SaturatedRatio = saturatedRatio,
P99 = p99,
MeanB = meanB.Val0,
MeanG = meanG.Val0,
MeanR = meanR.Val0,
MaxB = maxBVal,
MaxG = maxGVal,
MaxR = maxRVal,
};
}
}
} finally {
foreach (var ch in channels) ch.Dispose();
}
}
/// <summary>
/// CV_8U Mat の指定パーセンタイル値を累積ヒストグラムから算出する(ピュアロジック).
/// </summary>
/// <param name="gray">CV_8U の Mat</param>
/// <param name="percentile">パーセンタイル(0〜100)</param>
/// <returns>パーセンタイル値(0〜255 の実数)</returns>
internal static double CalcPercentile(Mat gray, double percentile) {
if (gray == null) throw new ArgumentNullException("gray");
if (gray.Type() != MatType.CV_8U)
throw new ArgumentException("gray must be CV_8U");
int total = gray.Rows * gray.Cols;
if (total == 0) return 0.0;
// ヒストグラムを計算(256 ビン,0〜255)
Mat[] srcArr = { gray };
int[] channels = { 0 };
Mat hist = new Mat();
try {
int[] histSize = { 256 };
Rangef[] ranges = { new Rangef(0, 256) };
Cv2.CalcHist(srcArr, channels, null, hist, 1, histSize, ranges);
double threshold = total * (percentile / 100.0);
double cumulative = 0.0;
for (int i = 0; i < 256; i++) {
cumulative += hist.At<float>(i);
if (cumulative >= threshold) {
return (double)i;
}
}
return 255.0;
} finally {
hist.Dispose();
}
}
// ---------------------------------------------------------------
// JSON 生成
// ---------------------------------------------------------------
/// <summary>
/// 撮影条件辞書と統計リストから JSON 文字列を生成する(ピュアロジック).
/// StringBuilder を使った手書きシリアライズにより,NuGet 追加依存なし.
/// </summary>
/// <param name="meta">撮影条件・メタ情報の辞書(キーはすべて ASCII 文字列)</param>
/// <param name="stats">フレームごとの統計リスト(null なら省略)</param>
/// <returns>整形済み JSON 文字列</returns>
internal static string BuildJson(Dictionary<string, object> meta, IList<FrameStats> stats) {
var sb = new StringBuilder();
sb.AppendLine("{");
// メタ情報
bool firstMeta = true;
foreach (var kvp in meta) {
if (!firstMeta) sb.AppendLine(",");
firstMeta = false;
sb.Append(" ");
AppendJsonString(sb, kvp.Key);
sb.Append(": ");
AppendJsonValue(sb, kvp.Value);
}
// フレーム統計
if (stats != null && stats.Count > 0) {
if (!firstMeta) sb.AppendLine(",");
sb.AppendLine(" \"frame_stats\": [");
for (int i = 0; i < stats.Count; i++) {
var s = stats[i];
sb.AppendLine(" {");
sb.AppendLine(" \"frame\": " + (i + 1) + ",");
sb.AppendLine(" \"saturated_ratio\": " + FormatDouble(s.SaturatedRatio) + ",");
sb.AppendLine(" \"p99_luminance\": " + FormatDouble(s.P99) + ",");
sb.AppendLine(" \"mean_B\": " + FormatDouble(s.MeanB) + ",");
sb.AppendLine(" \"mean_G\": " + FormatDouble(s.MeanG) + ",");
sb.AppendLine(" \"mean_R\": " + FormatDouble(s.MeanR) + ",");
sb.AppendLine(" \"max_B\": " + FormatDouble(s.MaxB) + ",");
sb.AppendLine(" \"max_G\": " + FormatDouble(s.MaxG) + ",");
sb.Append(" \"max_R\": " + FormatDouble(s.MaxR));
sb.AppendLine();
if (i < stats.Count - 1) {
sb.AppendLine(" },");
} else {
sb.AppendLine(" }");
}
}
sb.Append(" ]");
}
sb.AppendLine();
sb.Append("}");
return sb.ToString();
}
private static void AppendJsonString(StringBuilder sb, string s) {
sb.Append('"');
foreach (var c in s) {
if (c == '"') sb.Append("\\\"");
else if (c == '\\') sb.Append("\\\\");
else sb.Append(c);
}
sb.Append('"');
}
private static void AppendJsonValue(StringBuilder sb, object v) {
if (v == null) {
sb.Append("null");
} else if (v is bool bv) {
sb.Append(bv ? "true" : "false");
} else if (v is int iv) {
sb.Append(iv);
} else if (v is double dv) {
sb.Append(FormatDouble(dv));
} else if (v is float fv) {
sb.Append(FormatDouble(fv));
} else if (v is long lv) {
sb.Append(lv);
} else {
// 文字列として出力
AppendJsonString(sb, v.ToString());
}
}
private static string FormatDouble(double d) {
return d.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
}
}
}