using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using NUnit.Framework;
using OpenCvSharp;
namespace TIASshot.Tests {
/// <summary>
/// ImageStats のテスト(ピュアロジック層)
///
/// GUIDE_08: ハードウェア非依存の計算ロジックを NUnit で検証する。
/// ShotWhiteBoard・TryDisableColorEnhancement・Form1 ボタンはハードウェア結合層につき対象外。
///
/// 飽和定義: 輝度(BGR2GRAY: 0.114*B + 0.587*G + 0.299*R)が 254 以上の画素を飽和とカウントする。
/// TECH_02 Q8 では「チャンネル別 max」も記録対象とするが、飽和率の算出は輝度ベース(現実装の契約)。
/// </summary>
[TestFixture]
public class ImageStatsTests {
// ---------------------------------------------------------------
// ImageStats.Calc — ch別 mean / max
// ---------------------------------------------------------------
/// <summary>
/// Calc_全画素同一値_chMeanとchMaxが正しく返る:
/// 全画素 B=100, G=100, R=100 → MeanB/G/R=100, MaxB/G/R=100
/// </summary>
[Test]
public void Calc_全画素同一値_chMeanとchMaxが正しく返る() {
// Arrange: 2x2 の全画素が B=100, G=100, R=100
using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) {
// Act
var stats = ImageStats.Calc(mat);
// Assert: ch別 mean / max
Assert.AreEqual(100.0, stats.MeanB, 1e-3, "MeanB = 100");
Assert.AreEqual(100.0, stats.MeanG, 1e-3, "MeanG = 100");
Assert.AreEqual(100.0, stats.MeanR, 1e-3, "MeanR = 100");
Assert.AreEqual(100.0, stats.MaxB, 1e-3, "MaxB = 100");
Assert.AreEqual(100.0, stats.MaxG, 1e-3, "MaxG = 100");
Assert.AreEqual(100.0, stats.MaxR, 1e-3, "MaxR = 100");
}
}
/// <summary>
/// Calc_ch別に異なる値_各chのMeanとMaxが分離される:
/// 全画素 B=50, G=120, R=200 → MeanB=50, MeanG=120, MeanR=200(ch間の混在なし)
/// </summary>
[Test]
public void Calc_ch別に異なる値_各chのMeanとMaxが分離される() {
// Arrange: 3x3 の全画素が B=50, G=120, R=200(BGR 順)
using (var mat = new Mat(3, 3, MatType.CV_8UC3, new Scalar(50, 120, 200))) {
// Act
var stats = ImageStats.Calc(mat);
// Assert
Assert.AreEqual(50.0, stats.MeanB, 1e-3, "MeanB = 50");
Assert.AreEqual(120.0, stats.MeanG, 1e-3, "MeanG = 120");
Assert.AreEqual(200.0, stats.MeanR, 1e-3, "MeanR = 200");
Assert.AreEqual(50.0, stats.MaxB, 1e-3, "MaxB = 50");
Assert.AreEqual(120.0, stats.MaxG, 1e-3, "MaxG = 120");
Assert.AreEqual(200.0, stats.MaxR, 1e-3, "MaxR = 200");
}
}
/// <summary>
/// Calc_2行の異なる画素_chMaxが最大値を返す:
/// 行0: B=10, G=20, R=30、行1: B=50, G=60, R=70 → MaxB=50, MaxG=60, MaxR=70
/// MeanB=(10+50)/2=30(画素数=各行1画素の1x1ではなく2行1列の計2画素の平均)
/// </summary>
[Test]
public void Calc_2行異なる画素_chMaxが最大値を返す() {
// Arrange: 2行1列(2画素)、行ごとに値が異なる
using (var mat = new Mat(2, 1, MatType.CV_8UC3)) {
mat.At<Vec3b>(0, 0) = new Vec3b(10, 20, 30); // B=10, G=20, R=30
mat.At<Vec3b>(1, 0) = new Vec3b(50, 60, 70); // B=50, G=60, R=70
// Act
var stats = ImageStats.Calc(mat);
// Assert: MaxB=50, MaxG=60, MaxR=70(2画素の最大値)
Assert.AreEqual(50.0, stats.MaxB, 1e-3, "MaxB は 2 画素の最大値 50");
Assert.AreEqual(60.0, stats.MaxG, 1e-3, "MaxG は 2 画素の最大値 60");
Assert.AreEqual(70.0, stats.MaxR, 1e-3, "MaxR は 2 画素の最大値 70");
// Assert: MeanB=(10+50)/2=30, MeanG=(20+60)/2=40, MeanR=(30+70)/2=50
Assert.AreEqual(30.0, stats.MeanB, 1e-3, "MeanB = (10+50)/2 = 30");
Assert.AreEqual(40.0, stats.MeanG, 1e-3, "MeanG = (20+60)/2 = 40");
Assert.AreEqual(50.0, stats.MeanR, 1e-3, "MeanR = (30+70)/2 = 50");
}
}
// ---------------------------------------------------------------
// ImageStats.Calc — 飽和率(輝度ベース・BGR2GRAY)
// ---------------------------------------------------------------
/// <summary>
/// Calc_全画素同一値100_飽和率0:
/// 全画素 (B,G,R)=(100,100,100) → 輝度=100 → 飽和なし → SaturatedRatio=0.0
/// </summary>
[Test]
public void Calc_全画素同一値100_飽和率0() {
using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) {
var stats = ImageStats.Calc(mat);
Assert.AreEqual(0.0, stats.SaturatedRatio, 1e-6, "全輝度 100 → 飽和率 0");
}
}
/// <summary>
/// Calc_全画素255_飽和率1_0:
/// 全画素 (B,G,R)=(255,255,255) → 輝度=255 → 全画素飽和 → SaturatedRatio=1.0
/// </summary>
[Test]
public void Calc_全画素255_飽和率1_0() {
using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(255, 255, 255))) {
var stats = ImageStats.Calc(mat);
Assert.AreEqual(1.0, stats.SaturatedRatio, 1e-6, "全画素 255 → 飽和率 1.0");
}
}
/// <summary>
/// Calc_全画素255_p99は255:
/// 全画素 255 → p99=255 であること
/// </summary>
[Test]
public void Calc_全画素255_p99は255() {
using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(255, 255, 255))) {
var stats = ImageStats.Calc(mat);
Assert.AreEqual(255.0, stats.P99, 1e-3, "全画素 255 → p99 = 255");
}
}
/// <summary>
/// Calc_全画素同一値100_p99は100:
/// 全画素 (B,G,R)=(100,100,100) → 輝度=100 → p99=100
/// </summary>
[Test]
public void Calc_全画素同一値100_p99は100() {
using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) {
var stats = ImageStats.Calc(mat);
Assert.AreEqual(100.0, stats.P99, 1e-3, "全輝度 100 → p99 = 100");
}
}
/// <summary>
/// Calc_飽和画素割合の境界_SaturatedRatioが正しい割合:
/// 4画素のうち1画素だけ輝度 >= 254(全ch=255)、残り3画素は輝度 100
/// → SaturatedRatio = 1/4 = 0.25
///
/// 飽和の定義: 輝度(BGR2GRAY: 0.114*B + 0.587*G + 0.299*R)が ≥ 254 のとき飽和。
/// ch別に255であっても輝度 < 254 なら非飽和(例: B=255, G=0, R=0 → 輝度 ≒ 29)。
/// </summary>
[Test]
public void Calc_飽和画素1つ4画素中_SaturatedRatioが0_25() {
// Arrange: 2x2(4画素)、(0,0)のみ全ch=255(確実に輝度 >= 254)、残りは 100
using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) {
mat.At<Vec3b>(0, 0) = new Vec3b(255, 255, 255); // 輝度 = 255 >= 254 → 飽和
// Act
var stats = ImageStats.Calc(mat);
// Assert: 4画素中1画素飽和 → 1/4 = 0.25
Assert.AreEqual(0.25, stats.SaturatedRatio, 1e-6, "4画素中1画素飽和 → SaturatedRatio = 0.25");
}
}
/// <summary>
/// Calc_R=255のみ高輝度_輝度ベースで飽和判定されないこと:
/// B=0, G=0, R=255 → BGR2GRAY ≒ 0.299*255 ≒ 76.2 → 輝度 < 254 → 飽和なし
///
/// これは「チャンネル別に255でも輝度ベースでは非飽和」という定義の明示的な固定。
/// </summary>
[Test]
public void Calc_R255のみ_輝度ベースでは飽和しない() {
// Arrange: B=0, G=0, R=255(輝度 ≒ 76.2 → 254 未満)
using (var mat = new Mat(1, 1, MatType.CV_8UC3, new Scalar(0, 0, 255))) {
var stats = ImageStats.Calc(mat);
// Assert: 輝度ベースでは飽和なし
Assert.AreEqual(0.0, stats.SaturatedRatio, 1e-6,
"R=255のみ → BGR2GRAY ≒ 76 < 254 → 輝度ベース飽和率 = 0");
}
}
/// <summary>
/// Calc_G=255のみ_輝度ベースで飽和しない:
/// B=0, G=255, R=0 → BGR2GRAY ≒ 0.587*255 ≒ 149.7 → 輝度 < 254 → 飽和なし
/// </summary>
[Test]
public void Calc_G255のみ_輝度ベースでは飽和しない() {
using (var mat = new Mat(1, 1, MatType.CV_8UC3, new Scalar(0, 255, 0))) {
var stats = ImageStats.Calc(mat);
Assert.AreEqual(0.0, stats.SaturatedRatio, 1e-6,
"G=255のみ → BGR2GRAY ≒ 150 < 254 → 飽和率 = 0");
}
}
// ---------------------------------------------------------------
// ImageStats.CalcPercentile — 通常系
// ---------------------------------------------------------------
/// <summary>
/// CalcPercentile_全画素同一値150_p50は150:
/// 全画素 = 150 → p50 = 150
/// </summary>
[Test]
public void CalcPercentile_全画素同一値150_p50は150() {
using (var gray = new Mat(4, 4, MatType.CV_8U, new Scalar(150))) {
double p50 = ImageStats.CalcPercentile(gray, 50.0);
Assert.AreEqual(150.0, p50, 1e-3, "全画素 150 → p50 = 150");
}
}
/// <summary>
/// CalcPercentile_全画素同一値200_p99は200:
/// 全画素 = 200 → p99 = 200
/// </summary>
[Test]
public void CalcPercentile_全画素同一値200_p99は200() {
using (var gray = new Mat(4, 4, MatType.CV_8U, new Scalar(200))) {
double p99 = ImageStats.CalcPercentile(gray, 99.0);
Assert.AreEqual(200.0, p99, 1e-3, "全画素 200 → p99 = 200");
}
}
/// <summary>
/// CalcPercentile_全画素同一値100_p100は100:
/// 全画素 = 100 → p100 = 100
/// </summary>
[Test]
public void CalcPercentile_全画素同一値100_p100は100() {
using (var gray = new Mat(4, 4, MatType.CV_8U, new Scalar(100))) {
double p100 = ImageStats.CalcPercentile(gray, 100.0);
Assert.AreEqual(100.0, p100, 1e-3, "全画素 100 → p100 = 100");
}
}
/// <summary>
/// CalcPercentile_2種の輝度値_p50境界:
/// 2画素: 値=100 と 値=200 → p50 は threshold=2*0.5=1.0
/// bin[100]=1.0 で cumulative=1.0 >= 1.0 → p50=100
/// </summary>
[Test]
public void CalcPercentile_2画素100と200_p50は100() {
// Arrange: 1行2列(2画素)
using (var gray = new Mat(1, 2, MatType.CV_8U)) {
gray.At<byte>(0, 0) = 100;
gray.At<byte>(0, 1) = 200;
// Act
double p50 = ImageStats.CalcPercentile(gray, 50.0);
// Assert: threshold = 2 * 0.5 = 1.0。bin[100]=1.0 → cumulative=1.0 >= 1.0 → 100
Assert.AreEqual(100.0, p50, 1e-3, "p50: bin[100]=1 → cumulative=1 >= threshold=1 → p50=100");
}
}
/// <summary>
/// CalcPercentile_2画素100と200_p99は200:
/// threshold = 2 * 0.99 = 1.98。bin[100]=1.0 → cumulative=1.0 < 1.98。
/// bin[200]=1.0 → cumulative=2.0 >= 1.98 → p99=200
/// </summary>
[Test]
public void CalcPercentile_2画素100と200_p99は200() {
using (var gray = new Mat(1, 2, MatType.CV_8U)) {
gray.At<byte>(0, 0) = 100;
gray.At<byte>(0, 1) = 200;
double p99 = ImageStats.CalcPercentile(gray, 99.0);
Assert.AreEqual(200.0, p99, 1e-3, "p99=200(上位1%に 200 が含まれるため)");
}
}
// ---------------------------------------------------------------
// ImageStats.CalcPercentile — 空 Mat
// ---------------------------------------------------------------
/// <summary>
/// CalcPercentile_空Mat_0を返す:
/// 0行0列(画素数 = 0)→ total = 0 → 0.0 を返す
/// </summary>
[Test]
public void CalcPercentile_空Mat_0を返す() {
// Arrange: 0行0列の CV_8U
using (var gray = new Mat(0, 0, MatType.CV_8U)) {
double result = ImageStats.CalcPercentile(gray, 99.0);
Assert.AreEqual(0.0, result, 1e-6, "空 Mat → 0.0 を返す");
}
}
// ---------------------------------------------------------------
// ImageStats.BuildJson — 構造・キー・値の検証
// ---------------------------------------------------------------
/// <summary>
/// BuildJson_metaのみstatsなし_有効なJSONが生成される:
/// meta に string / int / bool / double を混在させ、JSON として parse できること
/// </summary>
[Test]
public void BuildJson_metaのみ_有効なJSON文字列が生成される() {
// Arrange
var meta = new Dictionary<string, object> {
{ "capture_datetime", "2026-06-07 12:00:00" },
{ "num_images", 5 },
{ "color_enhancement_off", true },
{ "exposure_us", 12345.6 },
};
// Act
string json = ImageStats.BuildJson(meta, null);
// Assert: null / 空でない
Assert.IsNotNull(json, "json が null であってはならない");
Assert.IsNotEmpty(json, "json が空文字列であってはならない");
// Assert: '{' / '}' で囲まれている(最低限の JSON 外枠)
Assert.IsTrue(json.TrimStart().StartsWith("{"), "JSON は '{' で始まる");
Assert.IsTrue(json.TrimEnd().EndsWith("}"), "JSON は '}' で終わる");
}
/// <summary>
/// BuildJson_metaの各型_キーと値が正しくシリアライズされる:
/// bool → true/false、int → 数値、double → G6/InvariantCulture 書式、string → クォート
/// </summary>
[Test]
public void BuildJson_bool値_truefalseがシリアライズされる() {
var meta = new Dictionary<string, object> {
{ "flag_on", true },
{ "flag_off", false },
};
string json = ImageStats.BuildJson(meta, null);
// bool は JSON の true / false リテラルになること
StringAssert.Contains("\"flag_on\": true", json, "true が literal true になること");
StringAssert.Contains("\"flag_off\": false", json, "false が literal false になること");
}
/// <summary>
/// BuildJson_int値_数値リテラルがシリアライズされる
/// </summary>
[Test]
public void BuildJson_int値_数値リテラルがシリアライズされる() {
var meta = new Dictionary<string, object> {
{ "num_images", 10 },
};
string json = ImageStats.BuildJson(meta, null);
// int は引用符なしの数値リテラル
StringAssert.Contains("\"num_images\": 10", json, "int は数値リテラルになること");
}
/// <summary>
/// BuildJson_double値_G6InvariantCultureで書式化される:
/// 1.23456789 → G6 書式(6桁有効数字)= "1.23457"(InvariantCulture: 小数点は '.')
/// </summary>
[Test]
public void BuildJson_double値_G6InvariantCultureで書式化される() {
var meta = new Dictionary<string, object> {
{ "val", 1.23456789 },
};
string json = ImageStats.BuildJson(meta, null);
// G6 + InvariantCulture: 1.23456789 → "1.23457"(四捨五入6桁)
StringAssert.Contains("\"val\": 1.23457", json, "double は G6 InvariantCulture で書式化される");
// ロケール依存のカンマ区切り(例: "1,23457")が混入していないこと
Assert.IsFalse(json.Contains("1,23457"), "小数点はカンマではなくピリオドを使う(InvariantCulture)");
}
/// <summary>
/// BuildJson_string値_ダブルクォートでエスケープされる:
/// 値中の '"' は '\"' にエスケープされること
/// </summary>
[Test]
public void BuildJson_string値中のダブルクォート_エスケープされる() {
var meta = new Dictionary<string, object> {
{ "desc", "say \"hello\"" },
};
string json = ImageStats.BuildJson(meta, null);
// '"' が '\\"' にエスケープされた文字列として含まれること
StringAssert.Contains("say \\\"hello\\\"", json, "\" は \\\" にエスケープされること");
}
/// <summary>
/// BuildJson_string値中のバックスラッシュ_エスケープされる:
/// 値中の '\' は '\\' にエスケープされること
/// </summary>
[Test]
public void BuildJson_string値中のバックスラッシュ_エスケープされる() {
var meta = new Dictionary<string, object> {
{ "path", @"C:\TIAS_Data" },
};
string json = ImageStats.BuildJson(meta, null);
// '\' が '\\' にエスケープされた文字列として含まれること
StringAssert.Contains(@"C:\\TIAS_Data", json, @"\ は \\ にエスケープされること");
}
/// <summary>
/// BuildJson_statsリスト_frame_statsキーと各フレームのフィールドが生成される:
/// 1フレーム分の FrameStats を与えたとき、"frame_stats" 配列が生成され
/// "frame", "saturated_ratio", "p99_luminance", "mean_B/G/R", "max_B/G/R" が含まれること
/// </summary>
[Test]
public void BuildJson_1フレームのstats_frame_statsキーと全フィールドが生成される() {
// Arrange
var meta = new Dictionary<string, object> { { "device", "DFK23UX249" } };
var stats = new List<FrameStats> {
new FrameStats {
SaturatedRatio = 0.0,
P99 = 181.0,
MeanB = 120.5,
MeanG = 130.0,
MeanR = 140.0,
MaxB = 200.0,
MaxG = 210.0,
MaxR = 220.0,
}
};
// Act
string json = ImageStats.BuildJson(meta, stats);
// Assert: "frame_stats" 配列が存在する
StringAssert.Contains("\"frame_stats\"", json, "frame_stats キーが存在すること");
// Assert: フレーム番号・各フィールドが含まれる
StringAssert.Contains("\"frame\": 1", json, "frame 番号 1 が含まれること");
StringAssert.Contains("\"saturated_ratio\"", json, "saturated_ratio キーが含まれること");
StringAssert.Contains("\"p99_luminance\"", json, "p99_luminance キーが含まれること");
StringAssert.Contains("\"mean_B\"", json, "mean_B キーが含まれること");
StringAssert.Contains("\"mean_G\"", json, "mean_G キーが含まれること");
StringAssert.Contains("\"mean_R\"", json, "mean_R キーが含まれること");
StringAssert.Contains("\"max_B\"", json, "max_B キーが含まれること");
StringAssert.Contains("\"max_G\"", json, "max_G キーが含まれること");
StringAssert.Contains("\"max_R\"", json, "max_R キーが含まれること");
}
/// <summary>
/// BuildJson_2フレームのstats_frame番号が1から始まり連番になる
/// </summary>
[Test]
public void BuildJson_2フレームのstats_frame番号が1から連番() {
var meta = new Dictionary<string, object> { { "n", 2 } };
var stats = new List<FrameStats> {
new FrameStats { SaturatedRatio = 0.0, P99 = 100.0 },
new FrameStats { SaturatedRatio = 0.0, P99 = 110.0 },
};
string json = ImageStats.BuildJson(meta, stats);
StringAssert.Contains("\"frame\": 1", json, "1枚目のフレーム番号は 1");
StringAssert.Contains("\"frame\": 2", json, "2枚目のフレーム番号は 2");
}
/// <summary>
/// BuildJson_statsがnull_frame_statsキーが生成されない
/// </summary>
[Test]
public void BuildJson_statsがnull_frame_statsキーが生成されない() {
var meta = new Dictionary<string, object> { { "key", "value" } };
string json = ImageStats.BuildJson(meta, null);
Assert.IsFalse(json.Contains("frame_stats"),
"stats が null のとき frame_stats キーは生成されない");
}
/// <summary>
/// BuildJson_statsが空リスト_frame_statsキーが生成されない
/// </summary>
[Test]
public void BuildJson_statsが空リスト_frame_statsキーが生成されない() {
var meta = new Dictionary<string, object> { { "key", "value" } };
string json = ImageStats.BuildJson(meta, new List<FrameStats>());
Assert.IsFalse(json.Contains("frame_stats"),
"stats が空リストのとき frame_stats キーは生成されない");
}
/// <summary>
/// BuildJson_SaturatedRatioが0_saturated_ratioが0にシリアライズされる:
/// SaturatedRatio = 0.0 → "saturated_ratio": 0
/// </summary>
[Test]
public void BuildJson_SaturatedRatioが0_JSON内で0が出力される() {
var meta = new Dictionary<string, object>();
var stats = new List<FrameStats> {
new FrameStats { SaturatedRatio = 0.0, P99 = 181.0 }
};
string json = ImageStats.BuildJson(meta, stats);
// G6 で 0.0 → "0"
StringAssert.Contains("\"saturated_ratio\": 0", json,
"飽和率 0 は JSON に 0 としてシリアライズされること");
}
// ---------------------------------------------------------------
// 異常系 — Calc の引数検証
// ---------------------------------------------------------------
/// <summary>
/// Calc_nullを渡す_ArgumentNullExceptionをスローする
/// </summary>
[Test]
public void Calc_null_ArgumentNullExceptionをスローする() {
Assert.Throws<ArgumentNullException>(
() => ImageStats.Calc(null),
"null Mat → ArgumentNullException");
}
/// <summary>
/// Calc_CV_8UC1のMatを渡す_ArgumentExceptionをスローする:
/// CV_8UC3 以外の Mat は受け付けない
/// </summary>
[Test]
public void Calc_CV8UC1Mat_ArgumentExceptionをスローする() {
using (var gray = new Mat(2, 2, MatType.CV_8U, new Scalar(100))) {
Assert.Throws<ArgumentException>(
() => ImageStats.Calc(gray),
"CV_8UC1 → ArgumentException(CV_8UC3 以外は不可)");
}
}
/// <summary>
/// Calc_CV_64FC3のMatを渡す_ArgumentExceptionをスローする:
/// 64 bit 浮動小数点型のカラー Mat も拒否される
/// </summary>
[Test]
public void Calc_CV64FC3Mat_ArgumentExceptionをスローする() {
using (var mat = new Mat(2, 2, MatType.CV_64FC3)) {
Assert.Throws<ArgumentException>(
() => ImageStats.Calc(mat),
"CV_64FC3 → ArgumentException(CV_8UC3 以外は不可)");
}
}
// ---------------------------------------------------------------
// 異常系 — CalcPercentile の引数検証
// ---------------------------------------------------------------
/// <summary>
/// CalcPercentile_nullを渡す_ArgumentNullExceptionをスローする
/// </summary>
[Test]
public void CalcPercentile_null_ArgumentNullExceptionをスローする() {
Assert.Throws<ArgumentNullException>(
() => ImageStats.CalcPercentile(null, 99.0),
"null Mat → ArgumentNullException");
}
/// <summary>
/// CalcPercentile_CV_8UC3のMatを渡す_ArgumentExceptionをスローする:
/// CalcPercentile は CV_8U(グレースケール)専用
/// </summary>
[Test]
public void CalcPercentile_CV8UC3Mat_ArgumentExceptionをスローする() {
using (var bgr = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) {
Assert.Throws<ArgumentException>(
() => ImageStats.CalcPercentile(bgr, 99.0),
"CV_8UC3 → ArgumentException(CV_8U 以外は不可)");
}
}
}
}