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 由来の決め打ちで動的読取ではない。