Newer
Older
TIASShot / TIASshot / Utils / ImageStats.cs
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);
        }
    }
}