diff --git a/CLAUDE.md b/CLAUDE.md
index 9af22d2..a401972 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@
## 開発進捗
-最新: PLAN_02 優先度2 完了(ConvertImage の中間 Mat を using で解放し OOM クラッシュを修正・回帰テスト追加)
+最新: 白板撮影モード(照明均一性評価用・DEBUG 限定)を DFK 23UX249 向けに実装(校正なしの素 RGB 撮影+飽和統計 JSON)
※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照.
## 必須ルール(コード実装時)
diff --git a/TIASshot.Tests/ImageStatsTests.cs b/TIASshot.Tests/ImageStatsTests.cs
new file mode 100644
index 0000000..4d9b0cf
--- /dev/null
+++ b/TIASshot.Tests/ImageStatsTests.cs
@@ -0,0 +1,572 @@
+using System;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using OpenCvSharp;
+
+namespace TIASshot.Tests {
+ ///
+ /// ImageStats のテスト(ピュアロジック層)
+ ///
+ /// GUIDE_08: ハードウェア非依存の計算ロジックを NUnit で検証する。
+ /// ShotWhiteBoard・TryDisableColorEnhancement・Form1 ボタンはハードウェア結合層につき対象外。
+ ///
+ /// 飽和定義: 輝度(BGR2GRAY: 0.114*B + 0.587*G + 0.299*R)が 254 以上の画素を飽和とカウントする。
+ /// TECH_02 Q8 では「チャンネル別 max」も記録対象とするが、飽和率の算出は輝度ベース(現実装の契約)。
+ ///
+ [TestFixture]
+ public class ImageStatsTests {
+
+ // ---------------------------------------------------------------
+ // ImageStats.Calc — ch別 mean / max
+ // ---------------------------------------------------------------
+
+ ///
+ /// Calc_全画素同一値_chMeanとchMaxが正しく返る:
+ /// 全画素 B=100, G=100, R=100 → MeanB/G/R=100, MaxB/G/R=100
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// Calc_ch別に異なる値_各chのMeanとMaxが分離される:
+ /// 全画素 B=50, G=120, R=200 → MeanB=50, MeanG=120, MeanR=200(ch間の混在なし)
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// 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画素の平均)
+ ///
+ [Test]
+ public void Calc_2行異なる画素_chMaxが最大値を返す() {
+ // Arrange: 2行1列(2画素)、行ごとに値が異なる
+ using (var mat = new Mat(2, 1, MatType.CV_8UC3)) {
+ mat.At(0, 0) = new Vec3b(10, 20, 30); // B=10, G=20, R=30
+ mat.At(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)
+ // ---------------------------------------------------------------
+
+ ///
+ /// Calc_全画素同一値100_飽和率0:
+ /// 全画素 (B,G,R)=(100,100,100) → 輝度=100 → 飽和なし → SaturatedRatio=0.0
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// Calc_全画素255_飽和率1_0:
+ /// 全画素 (B,G,R)=(255,255,255) → 輝度=255 → 全画素飽和 → SaturatedRatio=1.0
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// Calc_全画素255_p99は255:
+ /// 全画素 255 → p99=255 であること
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// Calc_全画素同一値100_p99は100:
+ /// 全画素 (B,G,R)=(100,100,100) → 輝度=100 → p99=100
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// 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)。
+ ///
+ [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(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");
+ }
+ }
+
+ ///
+ /// Calc_R=255のみ高輝度_輝度ベースで飽和判定されないこと:
+ /// B=0, G=0, R=255 → BGR2GRAY ≒ 0.299*255 ≒ 76.2 → 輝度 < 254 → 飽和なし
+ ///
+ /// これは「チャンネル別に255でも輝度ベースでは非飽和」という定義の明示的な固定。
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// Calc_G=255のみ_輝度ベースで飽和しない:
+ /// B=0, G=255, R=0 → BGR2GRAY ≒ 0.587*255 ≒ 149.7 → 輝度 < 254 → 飽和なし
+ ///
+ [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 — 通常系
+ // ---------------------------------------------------------------
+
+ ///
+ /// CalcPercentile_全画素同一値150_p50は150:
+ /// 全画素 = 150 → p50 = 150
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// CalcPercentile_全画素同一値200_p99は200:
+ /// 全画素 = 200 → p99 = 200
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// CalcPercentile_全画素同一値100_p100は100:
+ /// 全画素 = 100 → p100 = 100
+ ///
+ [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");
+ }
+ }
+
+ ///
+ /// CalcPercentile_2種の輝度値_p50境界:
+ /// 2画素: 値=100 と 値=200 → p50 は threshold=2*0.5=1.0
+ /// bin[100]=1.0 で cumulative=1.0 >= 1.0 → p50=100
+ ///
+ [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(0, 0) = 100;
+ gray.At(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");
+ }
+ }
+
+ ///
+ /// 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
+ ///
+ [Test]
+ public void CalcPercentile_2画素100と200_p99は200() {
+ using (var gray = new Mat(1, 2, MatType.CV_8U)) {
+ gray.At(0, 0) = 100;
+ gray.At(0, 1) = 200;
+
+ double p99 = ImageStats.CalcPercentile(gray, 99.0);
+ Assert.AreEqual(200.0, p99, 1e-3, "p99=200(上位1%に 200 が含まれるため)");
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // ImageStats.CalcPercentile — 空 Mat
+ // ---------------------------------------------------------------
+
+ ///
+ /// CalcPercentile_空Mat_0を返す:
+ /// 0行0列(画素数 = 0)→ total = 0 → 0.0 を返す
+ ///
+ [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 — 構造・キー・値の検証
+ // ---------------------------------------------------------------
+
+ ///
+ /// BuildJson_metaのみstatsなし_有効なJSONが生成される:
+ /// meta に string / int / bool / double を混在させ、JSON として parse できること
+ ///
+ [Test]
+ public void BuildJson_metaのみ_有効なJSON文字列が生成される() {
+ // Arrange
+ var meta = new Dictionary {
+ { "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 は '}' で終わる");
+ }
+
+ ///
+ /// BuildJson_metaの各型_キーと値が正しくシリアライズされる:
+ /// bool → true/false、int → 数値、double → G6/InvariantCulture 書式、string → クォート
+ ///
+ [Test]
+ public void BuildJson_bool値_truefalseがシリアライズされる() {
+ var meta = new Dictionary {
+ { "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 になること");
+ }
+
+ ///
+ /// BuildJson_int値_数値リテラルがシリアライズされる
+ ///
+ [Test]
+ public void BuildJson_int値_数値リテラルがシリアライズされる() {
+ var meta = new Dictionary {
+ { "num_images", 10 },
+ };
+
+ string json = ImageStats.BuildJson(meta, null);
+
+ // int は引用符なしの数値リテラル
+ StringAssert.Contains("\"num_images\": 10", json, "int は数値リテラルになること");
+ }
+
+ ///
+ /// BuildJson_double値_G6InvariantCultureで書式化される:
+ /// 1.23456789 → G6 書式(6桁有効数字)= "1.23457"(InvariantCulture: 小数点は '.')
+ ///
+ [Test]
+ public void BuildJson_double値_G6InvariantCultureで書式化される() {
+ var meta = new Dictionary {
+ { "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)");
+ }
+
+ ///
+ /// BuildJson_string値_ダブルクォートでエスケープされる:
+ /// 値中の '"' は '\"' にエスケープされること
+ ///
+ [Test]
+ public void BuildJson_string値中のダブルクォート_エスケープされる() {
+ var meta = new Dictionary {
+ { "desc", "say \"hello\"" },
+ };
+
+ string json = ImageStats.BuildJson(meta, null);
+
+ // '"' が '\\"' にエスケープされた文字列として含まれること
+ StringAssert.Contains("say \\\"hello\\\"", json, "\" は \\\" にエスケープされること");
+ }
+
+ ///
+ /// BuildJson_string値中のバックスラッシュ_エスケープされる:
+ /// 値中の '\' は '\\' にエスケープされること
+ ///
+ [Test]
+ public void BuildJson_string値中のバックスラッシュ_エスケープされる() {
+ var meta = new Dictionary {
+ { "path", @"C:\TIAS_Data" },
+ };
+
+ string json = ImageStats.BuildJson(meta, null);
+
+ // '\' が '\\' にエスケープされた文字列として含まれること
+ StringAssert.Contains(@"C:\\TIAS_Data", json, @"\ は \\ にエスケープされること");
+ }
+
+ ///
+ /// BuildJson_statsリスト_frame_statsキーと各フレームのフィールドが生成される:
+ /// 1フレーム分の FrameStats を与えたとき、"frame_stats" 配列が生成され
+ /// "frame", "saturated_ratio", "p99_luminance", "mean_B/G/R", "max_B/G/R" が含まれること
+ ///
+ [Test]
+ public void BuildJson_1フレームのstats_frame_statsキーと全フィールドが生成される() {
+ // Arrange
+ var meta = new Dictionary { { "device", "DFK23UX249" } };
+ var stats = new List {
+ 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 キーが含まれること");
+ }
+
+ ///
+ /// BuildJson_2フレームのstats_frame番号が1から始まり連番になる
+ ///
+ [Test]
+ public void BuildJson_2フレームのstats_frame番号が1から連番() {
+ var meta = new Dictionary { { "n", 2 } };
+ var stats = new List {
+ 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");
+ }
+
+ ///
+ /// BuildJson_statsがnull_frame_statsキーが生成されない
+ ///
+ [Test]
+ public void BuildJson_statsがnull_frame_statsキーが生成されない() {
+ var meta = new Dictionary { { "key", "value" } };
+
+ string json = ImageStats.BuildJson(meta, null);
+
+ Assert.IsFalse(json.Contains("frame_stats"),
+ "stats が null のとき frame_stats キーは生成されない");
+ }
+
+ ///
+ /// BuildJson_statsが空リスト_frame_statsキーが生成されない
+ ///
+ [Test]
+ public void BuildJson_statsが空リスト_frame_statsキーが生成されない() {
+ var meta = new Dictionary { { "key", "value" } };
+
+ string json = ImageStats.BuildJson(meta, new List());
+
+ Assert.IsFalse(json.Contains("frame_stats"),
+ "stats が空リストのとき frame_stats キーは生成されない");
+ }
+
+ ///
+ /// BuildJson_SaturatedRatioが0_saturated_ratioが0にシリアライズされる:
+ /// SaturatedRatio = 0.0 → "saturated_ratio": 0
+ ///
+ [Test]
+ public void BuildJson_SaturatedRatioが0_JSON内で0が出力される() {
+ var meta = new Dictionary();
+ var stats = new List {
+ 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 の引数検証
+ // ---------------------------------------------------------------
+
+ ///
+ /// Calc_nullを渡す_ArgumentNullExceptionをスローする
+ ///
+ [Test]
+ public void Calc_null_ArgumentNullExceptionをスローする() {
+ Assert.Throws(
+ () => ImageStats.Calc(null),
+ "null Mat → ArgumentNullException");
+ }
+
+ ///
+ /// Calc_CV_8UC1のMatを渡す_ArgumentExceptionをスローする:
+ /// CV_8UC3 以外の Mat は受け付けない
+ ///
+ [Test]
+ public void Calc_CV8UC1Mat_ArgumentExceptionをスローする() {
+ using (var gray = new Mat(2, 2, MatType.CV_8U, new Scalar(100))) {
+ Assert.Throws(
+ () => ImageStats.Calc(gray),
+ "CV_8UC1 → ArgumentException(CV_8UC3 以外は不可)");
+ }
+ }
+
+ ///
+ /// Calc_CV_64FC3のMatを渡す_ArgumentExceptionをスローする:
+ /// 64 bit 浮動小数点型のカラー Mat も拒否される
+ ///
+ [Test]
+ public void Calc_CV64FC3Mat_ArgumentExceptionをスローする() {
+ using (var mat = new Mat(2, 2, MatType.CV_64FC3)) {
+ Assert.Throws(
+ () => ImageStats.Calc(mat),
+ "CV_64FC3 → ArgumentException(CV_8UC3 以外は不可)");
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // 異常系 — CalcPercentile の引数検証
+ // ---------------------------------------------------------------
+
+ ///
+ /// CalcPercentile_nullを渡す_ArgumentNullExceptionをスローする
+ ///
+ [Test]
+ public void CalcPercentile_null_ArgumentNullExceptionをスローする() {
+ Assert.Throws(
+ () => ImageStats.CalcPercentile(null, 99.0),
+ "null Mat → ArgumentNullException");
+ }
+
+ ///
+ /// CalcPercentile_CV_8UC3のMatを渡す_ArgumentExceptionをスローする:
+ /// CalcPercentile は CV_8U(グレースケール)専用
+ ///
+ [Test]
+ public void CalcPercentile_CV8UC3Mat_ArgumentExceptionをスローする() {
+ using (var bgr = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) {
+ Assert.Throws(
+ () => ImageStats.CalcPercentile(bgr, 99.0),
+ "CV_8UC3 → ArgumentException(CV_8U 以外は不可)");
+ }
+ }
+ }
+}
diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj
index b2dbf0a..ba327ef 100644
--- a/TIASshot.Tests/TIASshot.Tests.csproj
+++ b/TIASshot.Tests/TIASshot.Tests.csproj
@@ -55,6 +55,7 @@
+
diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs
index cf2b263..ef475df 100644
--- a/TIASshot/Cameras/CameraBase.cs
+++ b/TIASshot/Cameras/CameraBase.cs
@@ -23,6 +23,17 @@
public abstract void Disconnect();
protected abstract void Shot(int numImages=1, int interval=0);
+#if DEBUG
+ ///
+ /// 白板撮影(DEBUG 限定).IScam でのみ実装.
+ /// 未対応の派生クラス(Lucam 等)はこのデフォルト実装でユーザーに通知して何もしない.
+ ///
+ /// 撮影枚数
+ public virtual void ShotWhiteBoard(int numImages) {
+ _form.ShowMessage("白板撮影は ImagingSource カメラのみ対応しています");
+ }
+#endif
+
// テンプレートメソッド用抽象メソッド(プレビュー/校正処理)
protected abstract void ApplyWhiteBalance(Scalar whitePatch);
protected abstract void LogCalibrationConverged();
diff --git a/TIASshot/Cameras/IScam.cs b/TIASshot/Cameras/IScam.cs
index 1e68838..1a45bf6 100644
--- a/TIASshot/Cameras/IScam.cs
+++ b/TIASshot/Cameras/IScam.cs
@@ -15,6 +15,9 @@
namespace TIASshot {
internal class IScam : CameraBase {
+
+ // Color Enhancement プロパティの GUID(DFK23UX249.xml に記載)
+ private static readonly Guid ColorEnhancementGuid = new Guid("{3A3A8F77-6440-46CC-940A-8752B02E6C29}");
ICImagingControl _ic;
VCDRangeProperty _brightness;
VCDRangeProperty _gain;
@@ -199,5 +202,166 @@
/// 撮影終了処理(IScam 固有): Shot 内でインライン実行済みのため空実装
///
protected override void EndShot() { }
+
+#if DEBUG
+ // ---------------------------------------------------------------
+ // 白板撮影(DEBUG 限定・照明均一性評価用)
+ // ---------------------------------------------------------------
+
+ ///
+ /// 白板撮影を実行する(DEBUG 限定).
+ /// チャート検出・WB 自動調整・TCC 色補正を一切行わず,カメラ素の RGB を保存する.
+ /// 校正未完了でも撮影可能.
+ ///
+ /// 撮影枚数
+ public override void ShotWhiteBoard(int numImages) {
+ var saveFolder = IoUtil.CreateSaveFolder("白板");
+
+ // Color Enhancement を OFF にする(DFK23UX249 専用プロパティ)
+ bool colorEnhancementDisabled = TryDisableColorEnhancement();
+ if (!colorEnhancementDisabled) {
+ Debug.WriteLine("[WhiteBoard] Color Enhancement の無効化をスキップしました(プロパティが存在しないか非対応)");
+ }
+
+ _ic.LiveStop();
+ _ic.Sink = _snapSink;
+ _ic.LiveStart();
+ var snapRef = _ic.Sink as FrameSnapSink;
+
+ try {
+ // 最初のフレームを捨てる(露出安定化)
+ snapRef.SnapSingle(TimeSpan.FromSeconds(5));
+
+ var statsList = new List();
+
+ for (int i = 0; i < numImages; i++) {
+ _form.ShowMessage($"白板撮影 {i + 1} / {numImages} 枚目");
+
+ var buffer = snapRef.SnapSingle(TimeSpan.FromSeconds(5));
+ Mat roiFrame = null;
+ try {
+ using (Mat img = Mat.FromPixelData(
+ buffer.FrameType.Height,
+ buffer.FrameType.Width,
+ MatType.CV_8UC3,
+ buffer.GetIntPtr())) {
+ using (Mat imgt_full = img.T()) {
+ // ROI 切り出し(通常撮影と同様)
+ roiFrame = new Mat(imgt_full, _roi).Clone();
+ }
+ }
+
+ // RGB 画像(PNG)として保存(sRGB 変換なし)
+ var filename = $"WhiteBoard_{i + 1:0000}.png";
+ Cv2.ImWrite(Path.Combine(saveFolder, filename), roiFrame);
+
+ // 飽和統計の算出
+ var stats = ImageStats.Calc(roiFrame);
+ statsList.Add(stats);
+
+ Debug.WriteLine(
+ $"[WhiteBoard] frame={i + 1} saturated={stats.SaturatedRatio:P2} " +
+ $"p99={stats.P99:F1} meanB={stats.MeanB:F1} meanG={stats.MeanG:F1} meanR={stats.MeanR:F1}");
+ } finally {
+ if (roiFrame != null) roiFrame.Dispose();
+ }
+ }
+
+ // JSON サイドカー出力
+ var jsonMeta = BuildWhiteBoardMeta(colorEnhancementDisabled, numImages);
+ var jsonStr = ImageStats.BuildJson(jsonMeta, statsList);
+ File.WriteAllText(
+ Path.Combine(saveFolder, "whiteboard_meta.json"),
+ jsonStr,
+ new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+
+ _form.ShowMessage($"白板撮影終了({numImages} 枚): {saveFolder}");
+ Debug.WriteLine($"[WhiteBoard] 保存完了: {saveFolder}");
+ } catch (Exception ex) {
+ Debug.WriteLine($"[WhiteBoard] 撮影エラー: {ex.Message}");
+ _form.ShowMessage($"白板撮影エラー: {ex.Message}");
+ throw;
+ } finally {
+ _ic.LiveStop();
+ _ic.Sink = _queueSink;
+ _ic.LiveStart();
+ }
+ }
+
+ ///
+ /// Color Enhancement を無効化する.
+ /// プロパティが存在しない機種では安全にスキップする(GUIDE_09: 空 catch 禁止).
+ ///
+ /// 無効化に成功した場合は true,プロパティ未存在でスキップした場合は false
+ private bool TryDisableColorEnhancement() {
+ VCDSwitchProperty prop = null;
+ try {
+ prop = _ic.VCDPropertyItems.Find(
+ ColorEnhancementGuid,
+ VCDGUIDs.VCDElement_Value);
+ } catch (Exception ex) {
+ // プロパティが存在しない機種では Find が例外を投げる場合がある
+ Debug.WriteLine($"[WhiteBoard] Color Enhancement プロパティの取得に失敗(スキップ): {ex.Message}");
+ return false;
+ }
+
+ if (prop == null) {
+ Debug.WriteLine("[WhiteBoard] Color Enhancement プロパティが存在しません(スキップ)");
+ return false;
+ }
+
+ prop.Switch = false;
+ Debug.WriteLine("[WhiteBoard] Color Enhancement を OFF にしました");
+ return true;
+ }
+
+ ///
+ /// JSON サイドカー用の撮影条件メタ辞書を構築する.
+ ///
+ private Dictionary BuildWhiteBoardMeta(bool colorEnhancementOff, int numImages) {
+ var meta = new Dictionary();
+ meta["capture_datetime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ meta["device_name"] = DeviceName ?? "Unknown";
+ meta["serial_number"] = SerialNumber ?? "Unknown";
+ meta["roi_x"] = _roi.X;
+ meta["roi_y"] = _roi.Y;
+ meta["roi_width"] = _roi.Width;
+ meta["roi_height"] = _roi.Height;
+ meta["num_frames"] = numImages;
+
+ // カメラプロパティ(デバイス状態ファイルの固定値を読み取り)
+ if (_exposure != null)
+ meta["exposure_ms"] = (double)_exposure.Value / 10.0;
+ if (_gain != null)
+ meta["gain"] = (double)_gain.Value;
+ if (_brightness != null)
+ meta["brightness"] = (int)_brightness.Value;
+ if (_gamma != null)
+ meta["gamma"] = (int)_gamma.Value;
+ if (_whiteBalanceBlue != null)
+ meta["wb_blue"] = (int)_whiteBalanceBlue.Value;
+ if (_whiteBalanceGreen != null)
+ meta["wb_green"] = (int)_whiteBalanceGreen.Value;
+ if (_whiteBalanceRed != null)
+ meta["wb_red"] = (int)_whiteBalanceRed.Value;
+
+ meta["color_enhancement_enabled"] = !colorEnhancementOff;
+ meta["color_enhancement_note"] = colorEnhancementOff
+ ? "Disabled before capture"
+ : "Not available (skipped)";
+
+ // 固定状態(device-state ファイルから既知の設定を明示)
+ meta["gamma_setting"] = "100 (linear, scale: 100=1.0)";
+ meta["tone_mapping"] = "OFF";
+ meta["denoise"] = "0 (OFF)";
+ meta["sharpness"] = "0 (OFF)";
+ meta["color_matrix"] = "N/A (ImagingSource: Color Enhancement used instead)";
+ meta["shading_correction"] = "未補正(LSC 保留)";
+ meta["shading_correction_note"] = "No flat-field/LSC correction applied. " +
+ "Measured uniformity includes lens vignetting.";
+
+ return meta;
+ }
+#endif
}
}
diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj
index eb9c3d4..25c30e2 100644
--- a/TIASshot/TIASshot.csproj
+++ b/TIASshot/TIASshot.csproj
@@ -123,6 +123,7 @@
+
Form
diff --git a/TIASshot/UI/Form1.Designer.cs b/TIASshot/UI/Form1.Designer.cs
index bfeeb14..e8357a6 100644
--- a/TIASshot/UI/Form1.Designer.cs
+++ b/TIASshot/UI/Form1.Designer.cs
@@ -49,11 +49,28 @@
this.icImagingControl1 = new TIS.Imaging.ICImagingControl();
this.txtConvElemNum = new System.Windows.Forms.TextBox();
this.label9 = new System.Windows.Forms.Label();
+#if DEBUG
+ this.btnWhiteBoard = new System.Windows.Forms.Button();
+#endif
((System.ComponentModel.ISupportInitialize)(this.picPreview)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.picDisplay)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.icImagingControl1)).BeginInit();
this.SuspendLayout();
- //
+#if DEBUG
+ //
+ // btnWhiteBoard [DEBUG]
+ //
+ this.btnWhiteBoard.BackColor = System.Drawing.Color.LightYellow;
+ this.btnWhiteBoard.Font = new System.Drawing.Font("MS UI Gothic", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(128)));
+ this.btnWhiteBoard.Location = new System.Drawing.Point(15, 375);
+ this.btnWhiteBoard.Name = "btnWhiteBoard";
+ this.btnWhiteBoard.Size = new System.Drawing.Size(268, 35);
+ this.btnWhiteBoard.TabIndex = 25;
+ this.btnWhiteBoard.Text = "[DEBUG] 白板撮影(均一性評価用)";
+ this.btnWhiteBoard.UseVisualStyleBackColor = false;
+ this.btnWhiteBoard.Click += new System.EventHandler(this.btnWhiteBoard_Click);
+#endif
+ //
// btnShotOne
//
this.btnShotOne.Font = new System.Drawing.Font("MS UI Gothic", 14.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(128)));
@@ -341,6 +358,9 @@
this.Controls.Add(this.label2);
this.Controls.Add(this.label1);
this.Controls.Add(this.btnShotOne);
+#if DEBUG
+ this.Controls.Add(this.btnWhiteBoard);
+#endif
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
this.Name = "Form1";
this.Text = "Form1";
@@ -382,6 +402,9 @@
private TIS.Imaging.ICImagingControl icImagingControl1;
private System.Windows.Forms.TextBox txtConvElemNum;
private System.Windows.Forms.Label label9;
+#if DEBUG
+ private System.Windows.Forms.Button btnWhiteBoard;
+#endif
}
}
diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs
index 90b801c..4c6a0c0 100644
--- a/TIASshot/UI/Form1.cs
+++ b/TIASshot/UI/Form1.cs
@@ -248,5 +248,40 @@
_isLightOn = true;
}
}
+
+#if DEBUG
+ ///
+ /// [DEBUG] 白板撮影ボタン(照明均一性評価用)
+ /// 校正ゲートを通さず IScam の白板撮影パスを呼び出す.
+ /// Release ビルドには含まれない.
+ ///
+ private void btnWhiteBoard_Click(object sender, EventArgs e) {
+ int numImages = GetNumMultiShots();
+ if (numImages < 1) numImages = 1;
+
+ btnWhiteBoard.Enabled = false;
+ ShowMessage($"白板撮影開始({numImages} 枚)...");
+
+ // 撮影は UI スレッドをブロックしないよう別スレッドで実行
+ var thread = new System.Threading.Thread(() => {
+ try {
+ _camera.ShotWhiteBoard(numImages);
+ } catch (Exception ex) {
+ System.Diagnostics.Debug.WriteLine($"[WhiteBoard] 撮影スレッドエラー: {ex.Message}");
+ ShowMessage($"白板撮影エラー: {ex.Message}");
+ } finally {
+ if (InvokeRequired) {
+ Invoke((MethodInvoker)delegate {
+ btnWhiteBoard.Enabled = true;
+ });
+ } else {
+ btnWhiteBoard.Enabled = true;
+ }
+ }
+ });
+ thread.Name = "WhiteBoardShotThread";
+ thread.Start();
+ }
+#endif
}
}
diff --git a/TIASshot/Utils/ImageStats.cs b/TIASshot/Utils/ImageStats.cs
new file mode 100644
index 0000000..52b8bae
--- /dev/null
+++ b/TIASshot/Utils/ImageStats.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using OpenCvSharp;
+
+namespace TIASshot {
+ ///
+ /// 1 フレーム分の飽和統計情報を保持する構造体(ピュアロジック).
+ ///
+ 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;
+ }
+
+ ///
+ /// 飽和統計の算出と JSON サイドカー生成を担う純粋ユーティリティクラス(ハードウェア非依存).
+ ///
+ /// GUIDE_08 ピュアロジック層に分類する:Mat を入力に統計を返す静的メソッド群,
+ /// および設定 dict → JSON 文字列変換メソッドを提供する.
+ /// [InternalsVisibleTo("TIASshot.Tests")] により TIASshot.Tests から呼び出せる.
+ ///
+ ///
+ internal static class ImageStats {
+
+ // ---------------------------------------------------------------
+ // 統計算出
+ // ---------------------------------------------------------------
+
+ ///
+ /// ROI 適用済みの BGR Mat から 1 フレーム分の飽和統計を算出する(ピュアロジック).
+ ///
+ /// CV_8UC3 の BGR Mat(ROI 適用後)
+ /// 統計情報
+ 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();
+ }
+ }
+
+ ///
+ /// CV_8U Mat の指定パーセンタイル値を累積ヒストグラムから算出する(ピュアロジック).
+ ///
+ /// CV_8U の Mat
+ /// パーセンタイル(0〜100)
+ /// パーセンタイル値(0〜255 の実数)
+ 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(i);
+ if (cumulative >= threshold) {
+ return (double)i;
+ }
+ }
+ return 255.0;
+ } finally {
+ hist.Dispose();
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // JSON 生成
+ // ---------------------------------------------------------------
+
+ ///
+ /// 撮影条件辞書と統計リストから JSON 文字列を生成する(ピュアロジック).
+ /// StringBuilder を使った手書きシリアライズにより,NuGet 追加依存なし.
+ ///
+ /// 撮影条件・メタ情報の辞書(キーはすべて ASCII 文字列)
+ /// フレームごとの統計リスト(null なら省略)
+ /// 整形済み JSON 文字列
+ internal static string BuildJson(Dictionary meta, IList 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);
+ }
+ }
+}
diff --git "a/docs/02_ENV/ENV_02_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.md" "b/docs/02_ENV/ENV_02_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.md"
index c85f6bc..180435d 100644
--- "a/docs/02_ENV/ENV_02_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.md"
+++ "b/docs/02_ENV/ENV_02_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.md"
@@ -37,7 +37,8 @@
│ ├── PreviewMonitor.cs ← プレビューモニタフォーム
│ └── PreviewMonitor.Designer.cs ← プレビューモニタのデザイナー自動生成コード
└── Utils/ ← ユーティリティ
- └── IoUtil.cs ← ファイル I/O(CSV・保存フォルダ・撮影情報)・サウンド
+ ├── IoUtil.cs ← ファイル I/O(CSV・保存フォルダ・撮影情報)・サウンド
+ └── ImageStats.cs ← 飽和統計算出・JSON 生成(白板撮影用・純粋ロジック)
```
### 設定・データファイル
diff --git "a/docs/05_TECH/TECH_01_\343\202\257\343\203\251\343\202\271\350\250\255\350\250\210.md" "b/docs/05_TECH/TECH_01_\343\202\257\343\203\251\343\202\271\350\250\255\350\250\210.md"
index d3998c6..91ff57f 100644
--- "a/docs/05_TECH/TECH_01_\343\202\257\343\203\251\343\202\271\350\250\255\350\250\210.md"
+++ "b/docs/05_TECH/TECH_01_\343\202\257\343\203\251\343\202\271\350\250\255\350\250\210.md"
@@ -66,6 +66,12 @@
- ゲイン更新比率の計算(ホワイトバランス自動調整).ハードウェアに依存しない純粋計算.
- `CameraBase.GetRatio` から委譲される(テスト可能なピュアロジック層).
+**ImageStats(静的クラス)**
+
+- ファイル: `Utils/ImageStats.cs`
+- 飽和統計(飽和率・p99・チャンネル別 mean/max)の算出と JSON サイドカー生成.ハードウェアに依存しない純粋計算.
+- 白板撮影モード(`#if DEBUG`)から使用される(テスト可能なピュアロジック層).
+
## 継承関係 (Inheritance)
```text
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 952b8b1..8142a70 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -53,3 +53,8 @@
- 方針: **挙動不変**(解放タイミングのみ変更)。返り値 `convImg8` は `using` の外で `ConvertTo` のコピー後に返却し use-after-dispose を作らない。テスト容易化のため `private`→`internal`(挙動不変)。
- テスト: テスト基盤が整ったため**自動回帰テストで担保**(恒等 conv での出力ピクセル一致=破損なし,3 回連続呼び出しの解放健全性等)。実機でも校正・1枚・連続10枚の出力健全を確認。
- 後段: 優先度4(`ConvertImage` の `src` in-place 書き換え)は未修正。現状挙動をテストで記録済み(修正時に検出可能)。
+
+- [x] **白板撮影モード(照明均一性評価用・DEBUG 限定)** (2026-06-07): SmTIAS との装置間比較(照明均一性評価)用に、ImagingSource DFK 23UX249 を対象とした白板撮影機能を追加。`#if DEBUG` 限定で配布版には出さない。
+ - 動機: SmTIAS-Evaluation 側と「TIAS の白板撮影が線形・無加工・固定設定で行えるか」を確認した回答(手元の参照資料 TECH_02/SPEC_03)に基づく実装。色を合わせるのが目的でないため**校正(チャート検出・WB 自動調整・TCC 色補正)を一切行わず素の RGB を保存**する独立撮影パスとした(既存の舌診撮影フローは不変)。
+ - 設計判断: 撮影前に DFK23 の Color Enhancement を OFF(非線形の色強調を排除)。飽和統計(飽和率・p99・ch別 mean/max)と撮影条件を JSON サイドカーで併置。統計算出と JSON 生成はピュアロジック(`Utils/ImageStats`)に分離し自動テスト(GUIDE_08)。実機で 10 枚撮影・Color Enhancement OFF・飽和率0・p99=181(露出適正)を確認。
+ - 保留・後段: LSC/ビネット補正は未実装(JSON に「未補正」と状態記録のみ)。フラットフィールド/ダークフレーム撮影・Lucam 対応は未対応。白板撮影後に Color Enhancement が OFF のまま戻らない(同一セッションで通常撮影に戻る場合は要再起動。`finally` での状態復元は今後の検討事項)。JSON の固定状態文字列(gamma_setting 等)は device-state 由来の決め打ちで動的読取ではない。