diff --git a/TIASshot/Cameras/CameraBase.HwTest.cs b/TIASshot/Cameras/CameraBase.HwTest.cs new file mode 100644 index 0000000..95a4872 --- /dev/null +++ b/TIASshot/Cameras/CameraBase.HwTest.cs @@ -0,0 +1,152 @@ +#if HWTEST +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenCvSharp; + +namespace TIASshot { + + /// + /// CameraBase の実機テスト用の観測口(HWTEST 限定.本番・SELFTEST のみのビルドには含まれない). + /// 校正の各段階の時刻(最初のフレーム・チャート検出・校正完了)と,校正後のフレーム・チャートマスクの複製を取る. + /// 本番の校正・撮影の処理の流れや値は変えない(読むだけ.複製は要求があるときだけ作る). + /// + internal abstract partial class CameraBase { + + // 校正の各段階の時刻(Stopwatch.GetTimestamp の値.0 = まだ).コールバックスレッドが書き,UI スレッドが読む + private long _hwTestFirstFrameAt = 0; + private long _hwTestChartDetectedAt = 0; + private long _hwTestCalibratedAt = 0; + + // 校正完了時の保存フォルダ(校正データ.CalcTcc の SetSaveFolder("校正") の結果) + private string _hwTestCalibrationFolder = null; + + // 校正後のフレームの複製の要求と結果(_hwTestCaptureLock で保護) + private readonly object _hwTestCaptureLock = new object(); + private bool _hwTestFrameRequested = false; + private Mat _hwTestCapturedFrame = null; + private List _hwTestCapturedMasks = null; + + /// このカメラのインスタンスで最初に校正処理(ProcessCalibrationFrame)にフレームが届いた時刻(Stopwatch のタイムスタンプ.0 = まだ) + internal long HwTestFirstFrameAt => Interlocked.Read(ref _hwTestFirstFrameAt); + + /// チャートを検出して校正(ホワイトバランスの調整)を始めた時刻(最後の検出.0 = まだ) + internal long HwTestChartDetectedAt => Interlocked.Read(ref _hwTestChartDetectedAt); + + /// 校正(変換行列の算出)が完了した時刻(0 = まだ) + internal long HwTestCalibratedAt => Interlocked.Read(ref _hwTestCalibratedAt); + + /// 校正データの保存フォルダ(校正完了前は null) + internal string HwTestCalibrationFolder => Volatile.Read(ref _hwTestCalibrationFolder); + + /// 直近の保存フォルダ(撮影の後は撮影データのフォルダ.UI スレッドの撮影処理が書く) + internal string HwTestSaveFolder => _saveFolder; + + /// + /// 保存スレッド(SaveThread.まだ起動していなければ null.終了済みのこともある). + /// タイムアウト時にウォッチドッグがスタックを取るためだけに使う(スレッドの操作はしない) + /// + internal Thread HwTestSaveThread => Volatile.Read(ref _saveThread); + + /// 変換行列の項目数の一覧(ColorCorrector の Calib/ConversionChannels の解釈そのもの) + internal IReadOnlyList HwTestConversionChannels => _colorCorrector.Channels.ToArray(); + + /// + /// 校正済みの状態で次に届くフレームとチャートマスクの複製を要求する(それまでの複製は解放する). + /// 複製はコールバックスレッドで作る(チャートマスクはコールバックスレッドだけが読み書きするため). + /// + internal void HwTestRequestCalibratedFrame() { + lock (_hwTestCaptureLock) { + ReleaseHwTestCapture(); + _hwTestFrameRequested = true; + } + } + + /// + /// 要求したフレームとチャートマスクの複製を取り出す(所有権は呼び出し側に移る.必ず Dispose すること). + /// + /// 複製があれば true(取り出した分はカメラ側から消える) + internal bool HwTestTakeCalibratedFrame(out Mat frame, out List masks) { + lock (_hwTestCaptureLock) { + frame = _hwTestCapturedFrame; + masks = _hwTestCapturedMasks; + _hwTestCapturedFrame = null; + _hwTestCapturedMasks = null; + return frame != null; + } + } + + /// + /// 校正で得た変換行列でフレームを sRGB に変換する(撮影の保存 SaveImages と同じ ColorCorrector.ConvertImage.入力は変えない). + /// + /// 変換するフレーム(BGR 8bit.呼び出し側が所有) + /// 変換行列の項目数 + /// 変換後の 8bit 画像(呼び出し側が Dispose する) + internal Mat HwTestConvertToSrgb(Mat frame, int channel) { + // ConvertImage は入力を in-place で CV_64FC3 に変えるため複製を渡す(SaveImages と同じ) + using (var clone = frame.Clone()) { + return _colorCorrector.ConvertImage(clone, channel); + } + } + + /// + /// 画素の値(BGR 8bit 階調の並び.長さは 3 の倍数)を校正で得た変換行列で sRGB に変換し,8bit に切り詰める前の値を返す + /// (HwTestConvertToSrgb と同じ計算で,最後の CV_8UC3 への変換だけを行わない.白飛びの調査用). + /// + /// 変換する画素の値(B0, G0, R0, B1, G1, R1, ...) + /// 変換行列の項目数 + /// 変換後の値(同じ並び.BGR) + internal double[] HwTestConvertPixelsUnclipped(double[] bgr, int channel) { + var n = bgr.Length / 3; + var result = new double[n * 3]; + if (n == 0) return result; + using (var pixels = new Mat(n, 1, MatType.CV_64FC3)) { + for (var i = 0; i < n; i++) { + pixels.At(i, 0) = new Vec3d(bgr[i * 3], bgr[i * 3 + 1], bgr[i * 3 + 2]); + } + using (var converted = _colorCorrector.HwTestConvertPixelsUnclipped(pixels, channel)) { + for (var i = 0; i < n; i++) { + for (var j = 0; j < 3; j++) result[i * 3 + j] = converted.At(i, j); + } + } + } + return result; + } + + /// + /// ProcessCalibrationFrame(コールバックスレッド)の先頭から呼ぶ観測. + /// 最初のフレームの時刻を記録し,校正済みでフレームの複製が要求されていれば,フレームとチャートマスクを複製する. + /// + private void HwTestObserveFrame(Mat imgt) { + Interlocked.CompareExchange(ref _hwTestFirstFrameAt, Stopwatch.GetTimestamp(), 0); + lock (_hwTestCaptureLock) { + if (!_hwTestFrameRequested || !_calibrated || _chartMasks.Count == 0) return; + _hwTestCapturedFrame = imgt.Clone(); + _hwTestCapturedMasks = _chartMasks.Select(m => m.Clone()).ToList(); + _hwTestFrameRequested = false; + } + } + + /// チャートを検出して校正を始めたことの記録(DetectChart から呼ぶ) + private void HwTestMarkChartDetected() { + Interlocked.Exchange(ref _hwTestChartDetectedAt, Stopwatch.GetTimestamp()); + } + + /// 校正の完了の記録(CalcTcc の最後から呼ぶ.このときの保存フォルダが校正データのフォルダ) + private void HwTestMarkCalibrated() { + Volatile.Write(ref _hwTestCalibrationFolder, _saveFolder); + Interlocked.Exchange(ref _hwTestCalibratedAt, Stopwatch.GetTimestamp()); + } + + private void ReleaseHwTestCapture() { + _hwTestCapturedFrame?.Dispose(); + _hwTestCapturedFrame = null; + if (_hwTestCapturedMasks != null) { + foreach (var m in _hwTestCapturedMasks) m?.Dispose(); + _hwTestCapturedMasks = null; + } + } + } +} +#endif diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index b6ff5fa..3bf0b1a 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -17,7 +17,7 @@ using OpenCvSharp; namespace TIASshot { - internal abstract class CameraBase { + internal abstract partial class CameraBase { // インターフェース public abstract bool Connect(); public abstract void Disconnect(); @@ -465,6 +465,8 @@ /// 処理対象 Mat(前処理済み) protected void ProcessCalibrationFrame(Mat imgt) { #if HWTEST + // 実機テストの観測(最初のフレームの時刻・校正後のフレームの複製.要求があるときだけ.CameraBase.HwTest.cs) + HwTestObserveFrame(imgt); // 実機テストのチャート不要の検査では校正を走らせない(本番・SELFTEST のみのビルドには含まれない) if (HwTestCalibrationSuppressed) return; #endif @@ -750,6 +752,9 @@ ReleaseChartMasks(); _chartMasks.AddRange(masks); _calibrating = Config.GetInt("Calib/Frames"); +#if HWTEST + HwTestMarkChartDetected(); +#endif #if MEMMONITOR PreviewProfiler.MarkEvent("CALIB_START"); #endif @@ -808,6 +813,9 @@ _form.ShowMessage(FormatCalibrationDone(imagesSaved)); _form.EnableShots(); _calibrated = true; +#if HWTEST + HwTestMarkCalibrated(); +#endif _lastCalibrationError = null; // 次に失敗したときは改めて通知する _lastCalibrationFailureReason = null; // チャート検出の表示を従来どおりに戻す } diff --git a/TIASshot/ColorCorrection/ColorCorrector.HwTest.cs b/TIASshot/ColorCorrection/ColorCorrector.HwTest.cs new file mode 100644 index 0000000..4ed56b9 --- /dev/null +++ b/TIASshot/ColorCorrection/ColorCorrector.HwTest.cs @@ -0,0 +1,25 @@ +#if HWTEST +using OpenCvSharp; + +namespace TIASshot { + + /// + /// ColorCorrector の実機テスト用の観測口(HWTEST 限定.本番・SELFTEST のみのビルドには含まれない). + /// 本番の変換(ConvertImage・ConvertColor)の処理や値は変えない(読むだけ). + /// + internal partial class ColorCorrector { + + /// + /// 画素の並び(n×1 の CV_64FC3.BGR)を校正で得た変換行列で sRGB に変換し,8bit に切り詰める前の値(n×3 の CV_64FC1.BGR)を返す. + /// ConvertImage(src, channel) と同じ計算(ExtendMat → 変換行列との積)で,最後の CV_8UC3 への変換(四捨五入と 0〜255 への飽和)だけを行わない. + /// 白飛び(255 を超えた値)の調査用. + /// + /// 画素の並び(n×1 の CV_64FC3.呼び出し側が所有) + /// 変換行列の項目数(校正済みであること) + /// 変換後の値(n×3.呼び出し側が Dispose する) + internal Mat HwTestConvertPixelsUnclipped(Mat pixelsBgr, int channel) { + return ConvertColor(pixelsBgr, _convRGB2SRGB[channel]); + } + } +} +#endif diff --git a/TIASshot/ColorCorrection/ColorCorrector.cs b/TIASshot/ColorCorrection/ColorCorrector.cs index de4458b..34cbe12 100644 --- a/TIASshot/ColorCorrection/ColorCorrector.cs +++ b/TIASshot/ColorCorrection/ColorCorrector.cs @@ -11,7 +11,7 @@ /// 色補正処理(TCC 変換行列の計算・色変換)を担うクラス. /// CameraBase から委譲される.チャート検出・ファイル I/O は呼び出し側に委ねる. /// - internal class ColorCorrector { + internal partial class ColorCorrector { // 参照値(変換目標) readonly Mat TCC_SRGB; diff --git a/TIASshot/HwTest/HwTestJudge.cs b/TIASshot/HwTest/HwTestJudge.cs index 7f30d50..bb56641 100644 --- a/TIASshot/HwTest/HwTestJudge.cs +++ b/TIASshot/HwTest/HwTestJudge.cs @@ -1,6 +1,7 @@ #if HWTEST using System; using System.Collections.Generic; +using System.Linq; namespace TIASshot { @@ -124,8 +125,149 @@ public static readonly TimeSpan ReconnectTimeout = TimeSpan.FromSeconds(60); public static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(30); - /// 実機テスト全体の上限(各項目の上限の合計より長くする) + /// 実機テスト全体の上限(チャート不要の検査.各項目の上限の合計より長くする) public static readonly TimeSpan OverallTimeout = TimeSpan.FromMinutes(10); + + // ==== チャートが必要な検査(--with-chart.PLAN_06 フェーズ 2).すべて暫定(最初の実機実行で決め直す) ==== + + /// calibrate: 接続から自動校正の完了(変換行列の算出)までの待機の上限(暫定.2026-10-08 の実機の実測に合わせて 120 秒から縮めた) + public static readonly TimeSpan CalibrationWait = TimeSpan.FromSeconds(60); + + /// calibrate: 校正の完了から撮影ボタンが有効になるまでの待機の上限(EnableShots は UI スレッドへの投稿) + public static readonly TimeSpan ShotsEnabledWait = TimeSpan.FromSeconds(5); + + /// calibrate: 校正後のフレームの複製が届くまでの待機の上限 + public static readonly TimeSpan CalibratedFrameWait = TimeSpan.FromSeconds(15); + + /// + /// calibrate: 校正後のフレームを変換した色票と参照値(tcc_srgb)の差の絶対値の平均の上限(8bit 階調.暫定). + /// セルフテスト(合成チャート・JPEG)の上限は 6.2026-10-08 の実機の実測(全次元で平均 1.57 以下)に合わせて 10 から縮めた + /// + public const double MaxCalibrationMeanError = 5.0; + + /// + /// calibrate: 同じく白色票を除いた色票の差の絶対値の最大の上限(8bit 階調.暫定.セルフテストは 15). + /// 2026-10-08 の実機の実測(白色票以外は 3.5 以下)に合わせた + /// + public const double MaxCalibrationMaxError = 10.0; + + /// + /// calibrate: 白色票(参照値から PatchColorError.WhitePatchIndex で決める.tcc_srgb.csv では 13 番)の差の絶対値の最大の上限(8bit 階調). + /// 【暫定】17 次元の変換で白色票が 255 に頭打ちになる(白飛び)疑いがあり(2026-10-08 の実測 27.35),その調査が済むまでの値 + /// + public const double MaxCalibrationWhiteMaxError = 40.0; + + /// shot: 連続撮影の枚数(暫定) + public const int ShotMultiCount = 3; + + /// shot: 連続撮影の間隔(ms)(暫定) + public const int ShotMultiIntervalMs = 1000; + + /// shot: 連続撮影の撮影時刻の間隔と設定値の差の許容(ms)(暫定.2026-10-08 の実機の実測に合わせて 200 ms から縮めた) + public const double ShotIntervalToleranceMs = 100.0; + + /// shot・close-saving: 撮影から保存(保存スレッドの終了)までの待機の上限(2026-10-09 に 90 秒から延ばした.重いアプリが動いていると保存が 1 枚あたり約 11 秒に遅くなり,close-saving の 10 枚の再試行で 90 秒を超えうるため) + public static readonly TimeSpan SaveWait = TimeSpan.FromSeconds(180); + + /// shot・close-saving: 保存の完了後,保存スレッドからの投稿(ShowMessage・EnableShots)を処理させる時間 + public static readonly TimeSpan AfterSaveSettle = TimeSpan.FromMilliseconds(500); + + /// close-saving: 保存中に閉じる検査で撮る連続撮影の枚数(間隔 0.保存に時間がかかるようにする)(暫定) + public const int CloseSavingShotCount = 5; + + /// close-saving: 保存中の状態を作れなかったときに 1 回だけ再試行する連続撮影の枚数(暫定) + public const int CloseSavingRetryShotCount = 10; + + /// close-saving: 保存中の状態を作る試行の回数の上限(最初の 1 回 + 再試行 1 回) + public const int CloseSavingMaxAttempts = 2; + + /// close-calibrating: 再接続から校正中(チャートを検出してホワイトバランスを調整中)になるまでの待機の上限(暫定) + public static readonly TimeSpan CalibratingWait = TimeSpan.FromSeconds(60); + + /// close-calibrating: 校正中になってから閉じる処理を呼ぶまでの時間(校正の途中で閉じるため.Calib/Frames 100 枚・約 15 fps で約 7 秒続く) + public static readonly TimeSpan CalibratingBeforeClose = TimeSpan.FromMilliseconds(500); + + public static readonly TimeSpan CalibrateTimeout = TimeSpan.FromSeconds(180); + public static readonly TimeSpan ShotTimeout = TimeSpan.FromSeconds(240); + public static readonly TimeSpan CloseSavingTimeout = TimeSpan.FromSeconds(300); + public static readonly TimeSpan CloseCalibratingTimeout = TimeSpan.FromSeconds(150); + + /// 実機テスト全体の上限(チャートが必要な検査.各項目の上限の合計(約 16 分)より長くする) + public static readonly TimeSpan OverallTimeoutWithChart = TimeSpan.FromMinutes(20); + + // ==== タイムアウト時の記録(止まった場所の調査用.判定には使わない) ==== + + /// タイムアウト時: 1 スレッドのマネージドスタックの取得の上限(Suspend が戻らない等でウォッチドッグ自体が止まらないため) + public static readonly TimeSpan HangStackTimeout = TimeSpan.FromSeconds(10); + + /// + /// タイムアウト時: ミニダンプの書き出しの上限.全メモリのダンプ(1〜3 GB 程度)を SSD に書く時間に余裕を持たせつつ, + /// ランナー(run_hwtest.py)の打ち切り(全体の上限 + 120 秒)より十分短くする + /// + public static readonly TimeSpan HangDumpTimeout = TimeSpan.FromSeconds(60); + + /// 全メモリのダンプにするプライベートメモリの上限(バイト).これを超えたら小さいダンプにする(ダンプが数 GB を超えないため) + public const long FullDumpMaxPrivateBytes = 3L * 1024 * 1024 * 1024; + + /// 全メモリのダンプに必要な空き容量の余裕(バイト).ダンプの大きさはプライベートメモリ+読み込んだモジュールの分になるため,その分を見込む + public const long FullDumpDiskMargin = 1L * 1024 * 1024 * 1024; + + /// 小さいダンプ(スレッド・スタックと参照先のメモリのみ.数十 MB)を書くのに必要な空き容量(バイト) + public const long SmallDumpMinFreeBytes = 256L * 1024 * 1024; + } + + /// + /// 色票内の画素の値の集計(HWTEST 限定.HwTestJudge.SummarizePatchPixels の結果). + /// 配列はチャネルの順(0 = B・1 = G・2 = R).画素が無い場合は値がすべて NaN. + /// NaN の画素は平均を NaN にする(最小・最大・割合には数えない). + /// + internal sealed class HwTestPatchPixelStats { + /// 画素の数 + public int Pixels; + /// チャネルごとの平均 + public readonly double[] Mean = new double[3]; + /// チャネルごとの最小 + public readonly double[] Min = new double[3]; + /// チャネルごとの最大 + public readonly double[] Max = new double[3]; + /// いずれかのチャネルが 255 以上の画素の割合(8bit 画像では 255 に頭打ちになった画素=白飛び) + public double FractionAnyAtOrAboveMax; + /// いずれかのチャネルが 0 以下の画素の割合(8bit 画像では 0 に頭打ちになった画素=黒つぶれ) + public double FractionAnyAtOrBelowMin; + /// いずれかのチャネルが 255 を超えた画素の割合(8bit に切り詰める前の値で意味がある.8bit 画像では常に 0) + public double FractionAnyAboveMax; + /// いずれかのチャネルが 0 未満の画素の割合(8bit に切り詰める前の値で意味がある.8bit 画像では常に 0) + public double FractionAnyBelowMin; + } + + /// 撮影ログ(Info.csv)の撮影の 1 行(HWTEST 限定) + internal struct HwTestShotLogRow { + /// 撮影の番号(1 始まり) + public int Shot; + /// 撮影開始からの時刻(ms) + public long TimeMs; + /// 備考(撮影の中止の理由等.無ければ空) + public string Note; + } + + /// 保存中に閉じる検査(close-saving)で保存中の状態を作れたかの判断(HWTEST 限定) + internal enum HwTestSavingAttempt { + /// 保存中の状態を作れた(閉じる処理の取り消しを検査する) + Ready, + /// 作れなかったので枚数を増やしてもう 1 回試す + Retry, + /// 作れなかった(試行の上限).WARN にして取り消しの検査を省く + GiveUp, + } + + /// タイムアウト時に書くミニダンプの種類(HWTEST 限定.HwTestJudge.ChooseDumpKind の結果) + internal enum HwTestDumpKind { + /// 書かない(空き容量が足りない) + None, + /// 小さいダンプ(スレッド・スタックと参照先のメモリ.マネージドのヒープは含まないため SOS の !clrstack は不完全になりうる) + Small, + /// 全メモリのダンプ(マネージドのスタック・ヒープを後から Visual Studio・WinDbg(SOS)で見られる) + Full, } /// ウォッチドッグの判定結果(HWTEST 限定) @@ -704,6 +846,301 @@ return exitCode == HwTestExitCodes.Fail || exitCode == HwTestExitCodes.Timeout; } + // ================================================================================ + // タイムアウト時の記録(スタック・ミニダンプ) + // ================================================================================ + + /// + /// タイムアウト時のミニダンプの保存先(作業フォルダ直下の hang-<日時>.dmp). + /// 作業フォルダはタイムアウト時に残る(ShouldKeepWorkDir)ため,ダンプも一緒に残る. + /// + /// 作業フォルダ(%TEMP%\TIASshot-hwtest\work-*) + /// タイムアウトを検知した時刻 + public static string HangDumpPath(string workDir, DateTime now) { + return System.IO.Path.Combine(workDir, $"hang-{now:yyyyMMdd-HHmmss}.dmp"); + } + + /// + /// タイムアウト時に書くミニダンプの種類.マネージドのスタックを後から見られるよう全メモリのダンプを優先する. + /// プライベートメモリが FullDumpMaxPrivateBytes を超える(ダンプが数 GB を超える)か, + /// 空き容量が「プライベートメモリ+ FullDumpDiskMargin」に満たない場合は小さいダンプにし, + /// 小さいダンプの分(SmallDumpMinFreeBytes)も空いていなければ書かない. + /// プライベートメモリが分からない(負)場合は大きさを見積もれないため小さいダンプにする. + /// + /// 自プロセスのプライベートメモリ(バイト.不明なら負) + /// 保存先のドライブの空き容量(バイト.不明なら負) + public static HwTestDumpKind ChooseDumpKind(long privateBytes, long freeDiskBytes) { + if (freeDiskBytes < HwTestThresholds.SmallDumpMinFreeBytes) return HwTestDumpKind.None; + if (privateBytes < 0 || privateBytes > HwTestThresholds.FullDumpMaxPrivateBytes) return HwTestDumpKind.Small; + // 加算のあふれは privateBytes の上限(3 GB)で起きない + if (freeDiskBytes < privateBytes + HwTestThresholds.FullDumpDiskMargin) return HwTestDumpKind.Small; + return HwTestDumpKind.Full; + } + + /// + /// ミニダンプの種類に対応する MINIDUMP_TYPE のフラグ(dbghelp.h の値). + /// Full: MiniDumpWithFullMemory(0x2) | WithHandleData(0x4) | WithUnloadedModules(0x20) | WithFullMemoryInfo(0x800) | WithThreadInfo(0x1000) + /// (procdump -ma と同じ組み合わせ.マネージドのスタック・ヒープを SOS・Visual Studio で解析するには全メモリが要る). + /// Small: WithDataSegs(0x1) | WithHandleData(0x4) | WithUnloadedModules(0x20) | WithIndirectlyReferencedMemory(0x40) | WithProcessThreadData(0x100) | WithThreadInfo(0x1000) + /// (ネイティブのスタックと,スタックから参照されるメモリ.マネージドのスタックは結果ファイルの文字列で補う). + /// None は 0(呼び出し側は書かない). + /// + public static uint DumpTypeFlags(HwTestDumpKind kind) { + switch (kind) { + case HwTestDumpKind.Full: return 0x2 | 0x4 | 0x20 | 0x800 | 0x1000; + case HwTestDumpKind.Small: return 0x1 | 0x4 | 0x20 | 0x40 | 0x100 | 0x1000; + default: return 0; + } + } + + // ================================================================================ + // チャートが必要な検査(--with-chart) + // ================================================================================ + + /// + /// 実行する検査の ID の一覧(結果ファイルの順序).チャート不要の検査と,チャートが必要な検査(--with-chart)は別に実行する + /// (校正が走ると露光・ゲイン・光源の測定が乱れるため.connect と cleanup は共通). + /// + public static string[] CheckIds(bool withChart) { + return withChart + ? new[] { "connect", "calibrate", "shot", "close-saving", "close-calibrating", "cleanup" } + : new[] { "connect", "preview", "exposure-gain", "light", "close-preview", "reconnect", "cleanup" }; + } + + /// 実機テスト全体の上限(チャートが必要な検査は校正・保存を待つため長い) + public static TimeSpan OverallTimeout(bool withChart) { + return withChart ? HwTestThresholds.OverallTimeoutWithChart : HwTestThresholds.OverallTimeout; + } + + /// + /// 2 つの時刻(Stopwatch のタイムスタンプ)の差(ms).どちらかが 0(まだ記録されていない)・逆順・frequency が 0 以下なら NaN. + /// + public static double TimestampDeltaMs(long from, long to, long frequency) { + if (from == 0 || to == 0 || to < from || frequency <= 0) return double.NaN; + return (to - from) * 1000.0 / frequency; + } + + /// + /// 校正後のフレームを変換した色票の誤差が上限内か. + /// 差の絶対値の平均 ≦ maxMean かつ 白色票を除いた最大 ≦ maxOtherMax かつ 白色票の最大 ≦ maxWhiteMax. + /// どれかが NaN(測定できない)なら false. + /// + /// 全色票 × BGR の差の絶対値の平均(PatchColorErrorStats.MeanAbs) + /// 白色票を除いた差の絶対値の最大(PatchColorError.MaxAbsExcluding) + /// 白色票の差の絶対値の最大(PatchColorError.PatchMaxAbs) + public static bool JudgeColorError(double meanAbs, double otherMaxAbs, double whiteMaxAbs, double maxMean, double maxOtherMax, double maxWhiteMax) { + if (double.IsNaN(meanAbs) || double.IsNaN(otherMaxAbs) || double.IsNaN(whiteMaxAbs)) return false; + return meanAbs <= maxMean && otherMaxAbs <= maxOtherMax && whiteMaxAbs <= maxWhiteMax; + } + + /// sRGB 8bit 階調の下端(これ以下は黒つぶれ) + public const double Srgb8Min = 0.0; + + /// sRGB 8bit 階調の上端(これ以上は白飛び) + public const double Srgb8Max = 255.0; + + /// + /// 色票内の画素の値(BGR の並び)の集計(白飛び・黒つぶれの調査用.参考値). + /// 8bit 画像(0〜255 に切り詰め済み)でも,8bit に切り詰める前の値(範囲外を含む)でも使う. + /// + /// 画素の値(B0, G0, R0, B1, G1, R1, ....長さは 3 の倍数) + /// bgr が null の場合 + /// 長さが 3 の倍数でない場合 + public static HwTestPatchPixelStats SummarizePatchPixels(IReadOnlyList bgr) { + if (bgr == null) throw new ArgumentNullException(nameof(bgr)); + if (bgr.Count % 3 != 0) throw new ArgumentException($"画素の値の並びの長さが 3 の倍数ではありません: {bgr.Count}", nameof(bgr)); + var n = bgr.Count / 3; + var s = new HwTestPatchPixelStats { Pixels = n }; + if (n == 0) { + for (var j = 0; j < 3; j++) s.Mean[j] = s.Min[j] = s.Max[j] = double.NaN; + s.FractionAnyAtOrAboveMax = s.FractionAnyAtOrBelowMin = s.FractionAnyAboveMax = s.FractionAnyBelowMin = double.NaN; + return s; + } + var sum = new double[3]; + for (var j = 0; j < 3; j++) { + s.Min[j] = double.PositiveInfinity; + s.Max[j] = double.NegativeInfinity; + } + int atOrAbove = 0, atOrBelow = 0, above = 0, below = 0; + for (var i = 0; i < n; i++) { + bool anyAtOrAbove = false, anyAtOrBelow = false, anyAbove = false, anyBelow = false; + for (var j = 0; j < 3; j++) { + var v = bgr[i * 3 + j]; + sum[j] += v; + if (v < s.Min[j]) s.Min[j] = v; + if (v > s.Max[j]) s.Max[j] = v; + if (v >= Srgb8Max) anyAtOrAbove = true; + if (v > Srgb8Max) anyAbove = true; + if (v <= Srgb8Min) anyAtOrBelow = true; + if (v < Srgb8Min) anyBelow = true; + } + if (anyAtOrAbove) atOrAbove++; + if (anyAtOrBelow) atOrBelow++; + if (anyAbove) above++; + if (anyBelow) below++; + } + for (var j = 0; j < 3; j++) s.Mean[j] = sum[j] / n; + s.FractionAnyAtOrAboveMax = (double)atOrAbove / n; + s.FractionAnyAtOrBelowMin = (double)atOrBelow / n; + s.FractionAnyAboveMax = (double)above / n; + s.FractionAnyBelowMin = (double)below / n; + return s; + } + + /// + /// 撮影ログ(Info.csv)の行から撮影の行を取り出す.見出し行「Shot,Time(ms),Note」の後の各行を + /// 「番号,時刻(ms)[,備考]」として読む(備考は RFC 4180 のエスケープのまま残す).空行は飛ばす. + /// + /// Info.csv の全行 + /// 読めない場合の理由(見出し行が無い・数値でない行).読めたら null + /// 撮影の行(読めない場合も読めた分までを返す) + public static List ParseShotLog(IReadOnlyList lines, out string error) { + error = null; + var rows = new List(); + if (lines == null) { + error = "撮影ログがありません"; + return rows; + } + var header = -1; + for (var i = 0; i < lines.Count; i++) { + if (lines[i] == "Shot,Time(ms),Note") { + header = i; + break; + } + } + if (header < 0) { + error = "見出し行「Shot,Time(ms),Note」がありません"; + return rows; + } + var inv = System.Globalization.CultureInfo.InvariantCulture; + for (var i = header + 1; i < lines.Count; i++) { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + var parts = line.Split(new[] { ',' }, 3); + int shot; + long time; + if (parts.Length < 2 + || !int.TryParse(parts[0], System.Globalization.NumberStyles.Integer, inv, out shot) + || !long.TryParse(parts[1], System.Globalization.NumberStyles.Integer, inv, out time)) { + error = $"{i + 1} 行目を撮影の行として読めません: {line}"; + return rows; + } + rows.Add(new HwTestShotLogRow { Shot = shot, TimeMs = time, Note = parts.Length > 2 ? parts[2] : "" }); + } + return rows; + } + + /// + /// 撮影時刻の間隔と設定値の差の絶対値の最大(ms).時刻が 2 つ未満なら NaN(間隔が無い). + /// + public static double MaxIntervalDeviationMs(IReadOnlyList timesMs, int intervalMs) { + if (timesMs == null || timesMs.Count < 2) return double.NaN; + double max = 0; + for (var i = 1; i < timesMs.Count; i++) { + var dev = Math.Abs((timesMs[i] - timesMs[i - 1]) - (double)intervalMs); + if (dev > max) max = dev; + } + return max; + } + + /// + /// 1 回の撮影で保存されるはずのファイル名の一覧(撮影ログ Info.csv・撮影画像 Shot{NO}・変換行列の項目数ごとの sRGB 変換画像 sRGB{CN}_{NO}). + /// 名前の組み立ては CameraBase.SaveImages と同じ({NO} は 4 桁の 1 始まり,{CN} は 2 桁). + /// + /// File/RgbImage(例 Shot{NO}.png) + /// File/SrgbImage(例 sRGB{CN}_{NO}.jpg) + /// File/Info(例 Info.csv) + /// 変換行列の項目数の一覧(null は無し) + /// 撮影枚数 + /// 名前の書式が null の場合 + public static List ExpectedShotFiles(string rgbPattern, string srgbPattern, string infoName, IEnumerable channels, int count) { + if (rgbPattern == null) throw new ArgumentNullException(nameof(rgbPattern)); + if (srgbPattern == null) throw new ArgumentNullException(nameof(srgbPattern)); + if (infoName == null) throw new ArgumentNullException(nameof(infoName)); + var names = new List { infoName }; + var channelList = channels?.ToList() ?? new List(); + for (var i = 0; i < count; i++) { + var no = $"{i + 1:0000}"; + names.Add(rgbPattern.Replace("{NO}", no)); + foreach (var ch in channelList) { + names.Add(srgbPattern.Replace("{CN}", $"{ch:00}").Replace("{NO}", no)); + } + } + return names; + } + + /// + /// 期待したファイル名と実際のファイル名を比べ,足りないもの(missing)と余分なもの(unexpected)を返す(大文字小文字は区別しない.名前順). + /// + public static void CompareFileNames(IEnumerable expected, IEnumerable actual, out List missing, out List unexpected) { + var exp = new HashSet(expected ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); + var act = new HashSet(actual ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); + missing = exp.Where(x => !act.Contains(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + unexpected = act.Where(x => !exp.Contains(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// 保存中に閉じる検査で,連続撮影の直後に保存中の状態を作れたかの判断. + /// 作れたら Ready,作れず試行が残っていれば Retry,残っていなければ GiveUp(FAIL ではなく WARN にする). + /// + /// 連続撮影の直後に保存中(CameraBase.IsSaving)だったか + /// 何回目の試行か(1 始まり) + /// 試行の回数の上限 + public static HwTestSavingAttempt DecideSavingAttempt(bool savingObserved, int attempt, int maxAttempts) { + if (savingObserved) return HwTestSavingAttempt.Ready; + return attempt < maxAttempts ? HwTestSavingAttempt.Retry : HwTestSavingAttempt.GiveUp; + } + + /// 保存中に閉じる検査の試行ごとの連続撮影の枚数(1 回目は CloseSavingShotCount,2 回目以降は CloseSavingRetryShotCount) + public static int CloseSavingShotCount(int attempt) { + return attempt <= 1 ? HwTestThresholds.CloseSavingShotCount : HwTestThresholds.CloseSavingRetryShotCount; + } + + /// + /// 保存中に閉じようとしたときに,閉じるのが正しく取り消されたかを調べ,問題点を返す(空なら合格). + /// 期待: 閉じると決まらない(e.Cancel)・表示が expectedMessage・後始末の段階が 1 つも進まない・閉じ始めのフラグが立たない・本モニタの再描画タイマーが動いたまま. + /// + /// 閉じる処理が閉じると決めたか(HwTestRequestClose の戻り値) + /// 閉じる処理の直後のステータス表示 + /// 期待する表示(Form1.SavingOnCloseMessage) + /// 閉じる処理で増えた後始末の段階の数 + /// 閉じ始めのフラグ + /// 本モニタの再描画タイマーが動作中か + public static List CancelledCloseProblems(bool closeAccepted, string shownMessage, string expectedMessage, + int stepsAdded, bool isClosing, bool refreshTimerRunning) { + var problems = new List(); + if (closeAccepted) problems.Add("保存中なのに閉じると決まりました(取り消されていません)"); + if (!string.Equals(shownMessage, expectedMessage, StringComparison.Ordinal)) { + problems.Add($"表示が「{shownMessage}」です(期待「{expectedMessage}」)"); + } + if (stepsAdded != 0) problems.Add($"後始末が {stepsAdded} 段階進みました(保存中は後始末を走らせない)"); + if (isClosing) problems.Add("閉じ始めのフラグが立っています"); + if (!refreshTimerRunning) problems.Add("本モニタの再描画タイマーが止まっています"); + return problems; + } + + /// 閉じる処理の後始末の段階の正しい順序(Form1.HwTestCloseSteps に記録される名前.close-preview・close-saving・close-calibrating 共通) + public static readonly IReadOnlyList CloseSequence = new[] { "closing", "timer", "camera", "light" }; + + /// + /// 閉じる処理の後始末の段階の記録のうち,startIndex 以降が「closing → timer → camera → light」(CloseSequence)の順に過不足なく並んでいるか + /// (記録は閉じる処理のたびに足されていくため,その回の閉じる処理の前の件数を startIndex に渡す). + /// + public static bool IsCloseSequenceSince(IReadOnlyList steps, int startIndex) { + if (steps == null || startIndex < 0 || startIndex > steps.Count) return false; + if (steps.Count - startIndex != CloseSequence.Count) return false; + for (var i = 0; i < CloseSequence.Count; i++) { + if (!string.Equals(steps[startIndex + i], CloseSequence[i], StringComparison.Ordinal)) return false; + } + return true; + } + + /// 後始末の段階の記録のうち startIndex 以降(その回の閉じる処理の分) + public static List StepsSince(IReadOnlyList steps, int startIndex) { + if (steps == null) return new List(); + return steps.Skip(Math.Max(0, startIndex)).ToList(); + } + private static void ValidateRange(double current, double min, double max) { if (!IsFinite(current) || !IsFinite(min) || !IsFinite(max)) { throw new ArgumentException($"設定値・範囲に非有限値が含まれます: {current}({min}〜{max})"); diff --git a/TIASshot/HwTest/HwTestOptions.cs b/TIASshot/HwTest/HwTestOptions.cs index 10c3107..8d46d70 100644 --- a/TIASshot/HwTest/HwTestOptions.cs +++ b/TIASshot/HwTest/HwTestOptions.cs @@ -29,7 +29,7 @@ /// 結果ファイルの出力先(省略時は null.呼び出し側が既定のパスを決める) public string OutPath { get; private set; } - /// チャートが必要な検査も行うか(フェーズ 2.現状は未実装) + /// チャートが必要な検査(calibrate・shot・close-saving・close-calibrating.PLAN_06 フェーズ 2)を行うか.指定するとチャート不要の検査は行わない(connect・cleanup は共通) public bool WithChart { get; private set; } /// 合格時も作業フォルダ(一時設定・保存物)を削除しないか diff --git a/TIASshot/HwTest/HwTestRunner.Chart.cs b/TIASshot/HwTest/HwTestRunner.Chart.cs new file mode 100644 index 0000000..d538e35 --- /dev/null +++ b/TIASshot/HwTest/HwTestRunner.Chart.cs @@ -0,0 +1,575 @@ +#if HWTEST +#if !SELFTEST +#error HWTEST は SELFTEST の部品(SelfTestPump・SelfTestCheck・PatchColorError)を使うため,SELFTEST と同時に定義すること +#endif +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Cv2 = OpenCvSharp.Cv2; +using Mat = OpenCvSharp.Mat; + +namespace TIASshot { + + /// + /// 実機テストのチャートが必要な検査(--with-chart.PLAN_06 フェーズ 2.HWTEST 限定). + /// 前提: カメラの前に舌診チャートがはまっている.校正は止めない(CameraBase.HwTestCalibrationSuppressed は false). + /// 順序: connect → calibrate → shot → close-saving → close-calibrating → cleanup. + /// 保存先は一時フォルダ(Config の File/SaveFolder は作業フォルダ配下に差し替え済み).判定はすべて HwTestThresholds の暫定しきい値と + /// HwTestJudge・PatchColorError の純粋関数で行い,測定値は合否にかかわらず詳細行に出す. + /// + internal static partial class HwTestRunner { + + // ================================================================================ + // calibrate + // ================================================================================ + + /// + /// 接続後のプレビューで自動校正(チャート検出 → ホワイトバランスの調整 → 変換行列の算出)が完了するのを待ち, + /// 所要時間の内訳・撮影ボタンの有効化・校正データ・校正後のフレームを変換した色票の参照値との差を確かめる. + /// + private static void CheckCalibrate(Session session, SelfTestCheck c) { + var form = session.Form; + var camera = session.Camera; + var freq = Stopwatch.Frequency; + + SetStep(session, "最初のフレームの待機"); + var first = SelfTestPump.PumpUntil(() => camera.HwTestFirstFrameAt != 0, HwTestThresholds.FirstFrameTimeout); + if (!c.Expect(first, $"{HwTestThresholds.FirstFrameTimeout.TotalSeconds:0} 秒以内にフレームが届きません(接続後 {form.HwTestFrameCount} フレーム)")) return; + + SetStep(session, "自動校正の完了の待機(チャート検出 → ホワイトバランスの調整 → 変換行列の算出)"); + var messages = new List(); + var done = SelfTestPump.PumpUntil(() => { + RecordDistinctMessage(messages, form.HwTestMessage); + return camera.HwTestIsCalibrated; + }, HwTestThresholds.CalibrationWait); + RecordDistinctMessage(messages, form.HwTestMessage); + + var t0 = camera.HwTestFirstFrameAt; + var tDetected = camera.HwTestChartDetectedAt; + var tCalibrated = camera.HwTestCalibratedAt; + c.Info($"校正の所要時間: 最初のフレーム → チャート検出(固定判定を含む) {FormatMs(HwTestJudge.TimestampDeltaMs(t0, tDetected, freq))}," + + $"チャート検出 → 校正完了(ホワイトバランス {Config.GetInt("Calib/Frames")} フレーム・変換行列の算出・校正データの保存) {FormatMs(HwTestJudge.TimestampDeltaMs(tDetected, tCalibrated, freq))}," + + $"合計 {FormatMs(HwTestJudge.TimestampDeltaMs(t0, tCalibrated, freq))}(上限 暫定 {HwTestThresholds.CalibrationWait.TotalSeconds:0} 秒)"); + c.Info($"校正中の表示(重複を除いた順): {string.Join(" → ", messages.Select(m => $"「{m}」"))}"); + if (!c.Expect(done, + $"{HwTestThresholds.CalibrationWait.TotalSeconds:0} 秒以内に自動校正が完了しません(状態: {(camera.IsCalibrating ? "校正中" : "チャート未検出")}," + + $"{form.HwTestFrameCount} フレーム,最後の表示「{form.HwTestMessage}」.チャートがはまっているか・照明を確認してください)")) return; + + SetStep(session, "撮影ボタンの有効化の待機"); + var enabled = SelfTestPump.PumpUntil(() => form.HwTestShotsEnabled, HwTestThresholds.ShotsEnabledWait); + c.Expect(enabled, "校正完了後に撮影ボタンが有効になりません(CalcTcc → EnableShots)", "校正完了後に撮影ボタンが有効になりました"); + c.Info($"校正完了後の表示: 「{form.HwTestMessage}」"); + + var refPath = Config.GetString("File/TccSrgbRef"); + using (var reference = IoUtil.LoadMatFromCsv(refPath)) { + if (!c.Expect(reference != null && reference.Rows == 24 && reference.Cols == 3, $"参照値({refPath})を 24x3 として読み込めません")) return; + var refArray = PatchColorError.ToArray(reference); + var channels = camera.HwTestConversionChannels; + c.Info($"変換行列の項目数(Calib/ConversionChannels): {string.Join(", ", channels)}"); + + CheckCalibrationData(session, c, camera.HwTestCalibrationFolder, channels, refArray); + CheckCalibratedFrame(session, c, camera, channels, refArray); + } + c.Info("カメラのホワイトバランスは校正で変わっています(次の接続でデバイス状態ファイルの値に戻ります.デバイス状態ファイルには書き込みません)"); + } + + /// + /// 校正データ(一時フォルダの「校正」フォルダ)の有無と,校正に使った色票の値を変換した値(TCC_sRGB{CN}.csv)の参照値との差(当てはめの誤差.参考)を出す. + /// + private static void CheckCalibrationData(Session session, SelfTestCheck c, string folder, IReadOnlyList channels, double[,] refArray) { + if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"校正データのフォルダがありません: {folder}")) return; + c.Expect(SelfTestRunner.IsUnder(folder, session.SaveRoot), $"校正データが一時フォルダの外に保存されました: {folder}", + $"校正データ: {folder}({Directory.GetFiles(folder).Length} ファイル)"); + foreach (var ch in channels) { + var name = Config.GetString("File/TccSrgbValues").Replace("{CN}", $"{ch:00}"); + var path = Path.Combine(folder, name); + using (var fitted = File.Exists(path) ? IoUtil.LoadMatFromCsv(path) : null) { + if (!c.Expect(fitted != null && fitted.Rows == 24 && fitted.Cols == 3, $"{name} を 24x3 として読み込めません: {path}")) continue; + var s = PatchColorError.Compare(PatchColorError.ToArray(fitted), refArray); + c.Info($"{ch} 次元: 校正に使った色票の値の変換値と参照値の差(当てはめの誤差.参考.{name}): {DescribeColorError(s)}"); + } + } + } + + /// + /// 校正後に届いたフレームを校正で得た変換行列で sRGB に変換し(撮影の保存と同じ ColorCorrector.ConvertImage), + /// 校正時のチャートマスクで 24 色票の平均値を取り,参照値(tcc_srgb)との差を変換行列の項目数ごとに判定する. + /// + private static void CheckCalibratedFrame(Session session, SelfTestCheck c, CameraBase camera, IReadOnlyList channels, double[,] refArray) { + SetStep(session, "校正後のフレームの複製の待機"); + Mat frame = null; + List masks = null; + camera.HwTestRequestCalibratedFrame(); + try { + var got = SelfTestPump.PumpUntil(() => camera.HwTestTakeCalibratedFrame(out frame, out masks), HwTestThresholds.CalibratedFrameWait); + if (!c.Expect(got, $"{HwTestThresholds.CalibratedFrameWait.TotalSeconds:0} 秒以内に校正後のフレームが届きません")) return; + if (!c.Expect(masks.Count == 24, $"チャートマスクが 24 個ではありません: {masks.Count} 個")) return; + c.Info($"校正後のフレーム: {frame.Width}x{frame.Height}"); + + // 白色票は参照値(BGR の和が最大の色票)から決める(tcc_srgb.csv の行の並びに依存させない) + var white = PatchColorError.WhitePatchIndex(refArray); + var rawWhite = Cv2.Mean(frame, masks[white]); + c.Info($"変換前の白色票({white + 1} 番.参照値の BGR の和が最大の色票): B{rawWhite.Val0:0.0} G{rawWhite.Val1:0.0} R{rawWhite.Val2:0.0}" + + $"(校正の目標 {Config.GetString("Calib/Reference/B")}/{Config.GetString("Calib/Reference/G")}/{Config.GetString("Calib/Reference/R")}.参考)"); + + // 変換前の色票内の画素(白飛びの調査で 8bit に切り詰める前の値を求めるため.並びは変換後の画素と同じ) + var rawPixels = masks.Select(m => MaskedPixels(frame, m)).ToList(); + c.Info($"変換前の白色票({white + 1} 番)の画素: {DescribePixelStats(HwTestJudge.SummarizePatchPixels(rawPixels[white]), false)}"); + + var evidenceDir = session.Options.Keep ? PrepareEvidenceDir(session, c) : null; + if (evidenceDir != null) SaveEvidenceImage(c, evidenceDir, "calibrated-frame-raw.png", frame); + + foreach (var ch in channels) { + SetStep(session, $"校正後のフレームの変換({ch} 次元)と色票の差"); + var measured = new double[24, 3]; + var clipped = new HwTestPatchPixelStats[24]; + using (var converted = camera.HwTestConvertToSrgb(frame, ch)) { + for (var i = 0; i < 24; i++) { + var mean = Cv2.Mean(converted, masks[i]); + for (var j = 0; j < 3; j++) measured[i, j] = mean[j]; + clipped[i] = HwTestJudge.SummarizePatchPixels(MaskedPixels(converted, masks[i])); + } + if (evidenceDir != null) SaveEvidenceImage(c, evidenceDir, $"calibrated-frame-srgb{ch:00}.png", converted); + } + + SetStep(session, $"校正後のフレームの 8bit に切り詰める前の値({ch} 次元)"); + var unclipped = new HwTestPatchPixelStats[24]; + var unclippedMeans = new double[24, 3]; + for (var i = 0; i < 24; i++) { + unclipped[i] = HwTestJudge.SummarizePatchPixels(camera.HwTestConvertPixelsUnclipped(rawPixels[i], ch)); + for (var j = 0; j < 3; j++) unclippedMeans[i, j] = unclipped[i].Mean[j]; + } + + var s = PatchColorError.Compare(measured, refArray); + var otherMax = PatchColorError.MaxAbsExcluding(measured, refArray, white, out var otherMaxPatch); + var whiteMax = PatchColorError.PatchMaxAbs(measured, refArray, white); + var ok = HwTestJudge.JudgeColorError(s.MeanAbs, otherMax, whiteMax, + HwTestThresholds.MaxCalibrationMeanError, HwTestThresholds.MaxCalibrationMaxError, HwTestThresholds.MaxCalibrationWhiteMaxError); + var rule = $"上限 暫定 平均 {HwTestThresholds.MaxCalibrationMeanError} / 白色票を除いた最大 {HwTestThresholds.MaxCalibrationMaxError}" + + $" / 白色票 {HwTestThresholds.MaxCalibrationWhiteMaxError}(白飛びの調査までの暫定)"; + var split = $"白色票({white + 1} 番)を除いた最大 {otherMax:0.00}({otherMaxPatch + 1} 番)・白色票の最大 {whiteMax:0.00}"; + c.Expect(ok, + $"{ch} 次元: 校正後のフレームを変換した色票が参照値から離れています: {DescribeColorError(s)},{split}({rule})", + $"{ch} 次元: 校正後のフレームを変換した色票と参照値の差: {DescribeColorError(s)},{split}({rule})"); + c.Info($"{ch} 次元: 色票ごとの差の最大(1〜24 番.8bit 階調): {DescribePerPatch(measured, refArray)}"); + + // 白飛び(255 への頭打ち)の調査用の参考値(判定には使わない) + c.Info($"{ch} 次元: 白色票({white + 1} 番)の変換後の 8bit の画素: {DescribePixelStats(clipped[white], false)}"); + c.Info($"{ch} 次元: 白色票({white + 1} 番)の 8bit に切り詰める前の画素: {DescribePixelStats(unclipped[white], true)}" + + $"(参照値 B{refArray[white, 0]:0.0} G{refArray[white, 1]:0.0} R{refArray[white, 2]:0.0})"); + c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 255 の画素の割合(%.1〜24 番.変換後の 8bit): {DescribePerPatchFraction(clipped, x => x.FractionAnyAtOrAboveMax)}"); + c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 0 の画素の割合(%.1〜24 番.変換後の 8bit): {DescribePerPatchFraction(clipped, x => x.FractionAnyAtOrBelowMin)}"); + c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 255 を超えた画素の割合(%.1〜24 番.8bit に切り詰める前): {DescribePerPatchFraction(unclipped, x => x.FractionAnyAboveMax)}"); + c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 0 未満の画素の割合(%.1〜24 番.8bit に切り詰める前): {DescribePerPatchFraction(unclipped, x => x.FractionAnyBelowMin)}"); + c.Info($"{ch} 次元: 色票ごとの 8bit に切り詰める前の最大(1〜24 番.B/G/R): {DescribePerPatchMax(unclipped)}"); + c.Info($"{ch} 次元: 色票ごとの変換後の 8bit の平均・最小・最大(1〜24 番.B/G/R): {DescribePerPatchStats(clipped)}"); + c.Info($"{ch} 次元: 参考 8bit に切り詰める前の値で色票の平均を取った場合の参照値との差: {DescribeColorError(PatchColorError.Compare(unclippedMeans, refArray))}"); + } + } finally { + frame?.Dispose(); + if (masks != null) { + foreach (var m in masks) m?.Dispose(); + } + } + } + + private static string DescribeColorError(PatchColorErrorStats s) { + return $"チャネルごとの差の絶対値(8bit 階調.{s.Patches} 色票 × BGR)の平均 {s.MeanAbs:0.00}・最大 {s.MaxAbs:0.00}({s.MaxAbsPatch + 1} 番)," + + $"参考 ΔE(CIE76.sRGB D65→Lab)の平均 {s.MeanDeltaE:0.00}・最大 {s.MaxDeltaE:0.00}({s.MaxDeltaEPatch + 1} 番)"; + } + + private static string DescribePerPatch(double[,] measured, double[,] reference) { + var parts = new List(); + for (var i = 0; i < measured.GetLength(0); i++) { + parts.Add($"{PatchColorError.PatchMaxAbs(measured, reference, i):0.0}"); + } + return string.Join(" ", parts); + } + + /// + /// マスク(CV_8UC1.0 以外が色票)の中の画素の値を BGR の並びで取り出す(行 → 列の順.同じマスクなら画像が違っても同じ並び). + /// 画像は CV_8UC3(8bit)を受け付ける. + /// + private static double[] MaskedPixels(Mat image, Mat mask) { + if (image.Type() != OpenCvSharp.MatType.CV_8UC3) throw new ArgumentException($"画素の取り出しは CV_8UC3 の画像だけに対応します({image.Type()})", nameof(image)); + if (image.Size() != mask.Size()) throw new ArgumentException($"画像とマスクの大きさが違います(画像 {image.Size()},マスク {mask.Size()})", nameof(mask)); + var rect = Cv2.BoundingRect(mask); + var values = new List(); + for (var y = rect.Top; y < rect.Bottom; y++) { + for (var x = rect.Left; x < rect.Right; x++) { + if (mask.At(y, x) == 0) continue; + var p = image.At(y, x); + values.Add(p.Item0); + values.Add(p.Item1); + values.Add(p.Item2); + } + } + return values.ToArray(); + } + + /// + /// 証拠の画像(--hwtest-keep のときだけ)の保存先を作業フォルダの下に作る.一時フォルダ(作業フォルダ)の外になる場合は作らない. + /// + /// 保存先(作れなければ null.理由は詳細行に出す) + private static string PrepareEvidenceDir(Session session, SelfTestCheck c) { + var dir = Path.Combine(session.WorkDir, "evidence"); + if (!SelfTestRunner.IsUnder(dir, session.WorkDir) || !SelfTestRunner.IsUnder(dir, Path.GetTempPath())) { + c.Fail($"証拠の画像の保存先が一時フォルダ(作業フォルダ)の外です.保存しません: {dir}"); + return null; + } + try { + Directory.CreateDirectory(dir); + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { + c.Warn($"証拠の画像の保存先を作れません.保存しません: {dir}: {ex.Message}"); + return null; + } + c.Info($"証拠の画像(--hwtest-keep)の保存先: {dir}"); + return dir; + } + + private static void SaveEvidenceImage(SelfTestCheck c, string dir, string name, Mat image) { + var path = Path.Combine(dir, name); + if (IoUtil.SaveImage(path, image)) { + c.Info($"証拠の画像を保存しました: {name}"); + } else { + c.Warn($"証拠の画像を保存できません: {path}"); + } + } + + /// 色票内の画素の集計を 1 行にする(withUnclipped = true なら 255 超・0 未満の割合を出す.false なら 255・0 の割合) + private static string DescribePixelStats(HwTestPatchPixelStats s, bool withUnclipped) { + var text = $"{s.Pixels} 画素,平均 B{s.Mean[0]:0.0} G{s.Mean[1]:0.0} R{s.Mean[2]:0.0}," + + $"最小 B{s.Min[0]:0.0} G{s.Min[1]:0.0} R{s.Min[2]:0.0},最大 B{s.Max[0]:0.0} G{s.Max[1]:0.0} R{s.Max[2]:0.0},"; + return withUnclipped + ? text + $"いずれかのチャネルが 255 を超えた画素 {s.FractionAnyAboveMax:P2}・0 未満の画素 {s.FractionAnyBelowMin:P2}" + : text + $"いずれかのチャネルが 255 の画素 {s.FractionAnyAtOrAboveMax:P2}・0 の画素 {s.FractionAnyAtOrBelowMin:P2}"; + } + + private static string DescribePerPatchFraction(IEnumerable stats, Func fraction) { + return string.Join(" ", stats.Select(s => $"{fraction(s) * 100:0.0}")); + } + + private static string DescribePerPatchMax(IEnumerable stats) { + return string.Join(" ", stats.Select((s, i) => $"{i + 1}:{s.Max[0]:0}/{s.Max[1]:0}/{s.Max[2]:0}")); + } + + private static string DescribePerPatchStats(IEnumerable stats) { + return string.Join("; ", stats.Select((s, i) => + $"{i + 1}: 平均 {s.Mean[0]:0.0}/{s.Mean[1]:0.0}/{s.Mean[2]:0.0} 最小 {s.Min[0]:0}/{s.Min[1]:0}/{s.Min[2]:0} 最大 {s.Max[0]:0}/{s.Max[1]:0}/{s.Max[2]:0}")); + } + + private static string FormatMs(double ms) { + return double.IsNaN(ms) ? "(記録なし)" : $"{ms:0} ms"; + } + + /// 表示の履歴に直前と違う表示だけを足す(件数の上限つき.校正の途中の表示を結果ファイルに残すため) + private static void RecordDistinctMessage(List messages, string message) { + if (string.IsNullOrEmpty(message)) return; + if (messages.Count > 0 && messages[messages.Count - 1] == message) return; + if (messages.Count >= 30) return; + messages.Add(message); + } + + // ================================================================================ + // shot + // ================================================================================ + + /// + /// 単発撮影 1 回と連続撮影(HwTestThresholds.ShotMultiCount 枚・ShotMultiIntervalMs 間隔)を一時フォルダに行い, + /// 保存の完了(保存スレッドの終了)を待って,保存物・撮影ログ・撮影ボタンの再有効化を確かめる. + /// + private static void CheckShot(Session session, SelfTestCheck c) { + var form = session.Form; + var camera = session.Camera; + if (!c.Expect(session.CameraConnected && camera.HwTestIsCalibrated && form.HwTestShotsEnabled, + "校正が済んでいない(撮影ボタンが無効)ため撮影できません(前の calibrate を確認してください)")) return; + + var one = ShotAndVerify(session, c, "単発", "hwtest-shot-one", 1, 0, form.HwTestClickShotOne); + + var n = HwTestThresholds.ShotMultiCount; + var interval = HwTestThresholds.ShotMultiIntervalMs; + form.HwTestSetMultiShots(n, interval); + var multi = ShotAndVerify(session, c, "連続", "hwtest-shot-multi", n, interval, form.HwTestClickShotMulti); + + if (one != null && multi != null) { + c.Expect(!string.Equals(one, multi, StringComparison.OrdinalIgnoreCase), + $"単発と連続の撮影データが同じフォルダに保存されました: {one}"); + } + } + + /// + /// 撮影ボタンを押して(UI スレッドで同期に撮影.保存は保存スレッドで続く),保存の完了を待ち,保存物を確かめる. + /// + /// 撮影データのフォルダ(撮影・保存ができなければ null) + private static string ShotAndVerify(Session session, SelfTestCheck c, string label, string dataName, int count, int intervalMs, Action click) { + var form = session.Form; + var camera = session.Camera; + form.HwTestSetDataName(dataName); + var errorsBefore = form.HwTestErrors.Count; + + SetStep(session, $"{label}撮影({count} 枚・間隔 {intervalMs} ms)"); + var sw = Stopwatch.StartNew(); + click(); + var shotMs = sw.ElapsedMilliseconds; + var savingAfterShot = camera.IsSaving; + var enabledAfterShot = form.HwTestShotsEnabled; + c.Info($"{label}撮影: 撮影 {shotMs} ms(ボタンを押してから戻るまで),直後の状態: 保存中 {(savingAfterShot ? "はい" : "いいえ")},撮影ボタン {(enabledAfterShot ? "有効" : "無効")}"); + // 撮影ボタンは保存の完了まで有効にならない(保存スレッドが終わりに UI スレッドへ投稿する).保存中なのに有効なら不変条件の破れ + c.Expect(!(enabledAfterShot && savingAfterShot), $"{label}撮影: 保存中なのに撮影ボタンが有効です(保存の完了前に再有効化された)"); + + if (!WaitForSave(session, c, label)) return null; + + var folder = camera.HwTestSaveFolder; + VerifyShotFolder(session, c, label, folder, count, intervalMs); + // エラー通知(ShowError)は項目の終わりに RunCheck がまとめて FAIL にする.ここでは件数だけを出す + var newErrors = form.HwTestErrors.Count - errorsBefore; + c.Info(newErrors == 0 ? $"{label}撮影: エラー通知なし" : $"{label}撮影: エラー通知 {newErrors} 件(内容は項目の最後の FAIL)"); + c.Info($"{label}撮影: 保存完了後の表示「{form.HwTestMessage}」"); + return folder; + } + + /// 保存スレッドの終了を待ち,保存スレッドからの投稿を処理させてから撮影ボタンの再有効化を確かめる + /// 上限内に保存が終わったら true + private static bool WaitForSave(Session session, SelfTestCheck c, string label) { + var form = session.Form; + var camera = session.Camera; + SetStep(session, $"{label}: 保存の完了(保存スレッドの終了)の待機"); + var sw = Stopwatch.StartNew(); + var saved = SelfTestPump.PumpUntil(() => !camera.IsSaving, HwTestThresholds.SaveWait); + var saveMs = sw.ElapsedMilliseconds; + if (!c.Expect(saved, $"{label}: {HwTestThresholds.SaveWait.TotalSeconds:0} 秒以内に保存が終わりません(表示「{form.HwTestMessage}」)")) return false; + SelfTestPump.PumpFor(HwTestThresholds.AfterSaveSettle); + var enabled = SelfTestPump.PumpUntil(() => form.HwTestShotsEnabled, HwTestThresholds.ShotsEnabledWait); + c.Expect(enabled, $"{label}: 保存の完了後に撮影ボタンが再有効化されません(SaveThread → EnableShots(true))", + $"{label}: 撮影の後 {saveMs} ms で保存が終わり,撮影ボタンが再有効化されました"); + return true; + } + + /// + /// 撮影データのフォルダの中身(撮影画像・sRGB 変換画像・Info.csv の過不足と大きさ)と,撮影ログの行数・撮影時刻の間隔を確かめる. + /// + private static void VerifyShotFolder(Session session, SelfTestCheck c, string label, string folder, int count, int intervalMs) { + var camera = session.Camera; + if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"{label}: 撮影データのフォルダがありません: {folder}")) return; + c.Expect(SelfTestRunner.IsUnder(folder, session.SaveRoot), $"{label}: 撮影データが一時フォルダの外に保存されました: {folder}"); + c.Expect(!string.Equals(folder, camera.HwTestCalibrationFolder, StringComparison.OrdinalIgnoreCase), + $"{label}: 撮影データが校正データと同じフォルダに保存されました: {folder}"); + + var infoName = Config.GetString("File/Info"); + var expected = HwTestJudge.ExpectedShotFiles(Config.GetString("File/RgbImage"), Config.GetString("File/SrgbImage"), infoName, + camera.HwTestConversionChannels, count); + var files = Directory.GetFiles(folder); + HwTestJudge.CompareFileNames(expected, files.Select(Path.GetFileName), out var missing, out var unexpected); + var empty = files.Where(f => new FileInfo(f).Length == 0).Select(Path.GetFileName).ToList(); + c.Info($"{label}: 撮影データ {folder}({files.Length} ファイル.期待 {expected.Count}: 撮影画像 {count}・sRGB 変換画像 {count} × {camera.HwTestConversionChannels.Count}・{infoName})"); + c.Expect(missing.Count == 0, $"{label}: 保存されていないファイルがあります: {string.Join(", ", missing)}"); + if (unexpected.Count > 0) c.Warn($"{label}: 想定していないファイルがあります: {string.Join(", ", unexpected)}"); + c.Expect(empty.Count == 0, $"{label}: 大きさ 0 のファイルがあります: {string.Join(", ", empty)}"); + + var infoPath = Path.Combine(folder, infoName); + if (!File.Exists(infoPath)) return; + var rows = HwTestJudge.ParseShotLog(File.ReadAllLines(infoPath), out var parseError); + if (!c.Expect(parseError == null, $"{label}: 撮影ログ({infoName})を読めません: {parseError}")) return; + c.Info($"{label}: 撮影ログの撮影時刻(ms): {string.Join(", ", rows.Select(r => r.TimeMs))}"); + c.Expect(rows.Count == count, $"{label}: 撮影ログの行数が撮影枚数と違います: {rows.Count} 行(期待 {count})"); + var notes = rows.Where(r => !string.IsNullOrEmpty(r.Note)).Select(r => $"{r.Shot}: {r.Note}").ToList(); + c.Expect(notes.Count == 0, $"{label}: 撮影ログに中止等の備考があります: {string.Join(" / ", notes)}"); + if (count >= 2) { + var dev = HwTestJudge.MaxIntervalDeviationMs(rows.Select(r => r.TimeMs).ToList(), intervalMs); + c.Expect(!double.IsNaN(dev) && dev <= HwTestThresholds.ShotIntervalToleranceMs, + $"{label}: 撮影時刻の間隔が設定値({intervalMs} ms)から離れています: 差の最大 {dev:0} ms(許容 暫定 {HwTestThresholds.ShotIntervalToleranceMs} ms)", + $"{label}: 撮影時刻の間隔と設定値({intervalMs} ms)の差の最大 {dev:0} ms(許容 暫定 {HwTestThresholds.ShotIntervalToleranceMs} ms)"); + } + } + + // ================================================================================ + // close-saving + // ================================================================================ + + /// + /// 保存に時間がかかる連続撮影(間隔 0)の直後,保存中(CameraBase.IsSaving)に閉じる処理を呼び,閉じるのが取り消されること + /// (表示が Form1.SavingOnCloseMessage・後始末が走らない)を確かめる.保存の完了後にもう一度閉じる処理を呼び,閉じられること + /// (段階 closing → timer → camera → light)を確かめる.保存中の状態を作れなかった場合は WARN にして枚数を増やして 1 回だけ再試行する. + /// + private static void CheckCloseSaving(Session session, SelfTestCheck c) { + var form = session.Form; + var camera = session.Camera; + if (!c.Expect(session.CameraConnected && camera.HwTestIsCalibrated && form.HwTestShotsEnabled, + "校正が済んでいない(撮影ボタンが無効)ため保存中の状態を作れません(前の calibrate・shot を確認してください)")) return; + var lightWasOpen = form.HwTestLightIsOpen; + + var decision = HwTestSavingAttempt.GiveUp; + string folder = null; + var count = 0; + for (var attempt = 1; ; attempt++) { + count = HwTestJudge.CloseSavingShotCount(attempt); + form.HwTestSetDataName($"hwtest-close-saving-{attempt}"); + form.HwTestSetMultiShots(count, 0); + SetStep(session, $"連続撮影({count} 枚・間隔 0 ms.試行 {attempt})"); + var sw = Stopwatch.StartNew(); + form.HwTestClickShotMulti(); + var saving = camera.IsSaving; + folder = camera.HwTestSaveFolder; + c.Info($"試行 {attempt}: 連続撮影 {count} 枚(間隔 0 ms)の撮影 {sw.ElapsedMilliseconds} ms,直後の保存中: {(saving ? "はい" : "いいえ")}"); + decision = HwTestJudge.DecideSavingAttempt(saving, attempt, HwTestThresholds.CloseSavingMaxAttempts); + if (decision == HwTestSavingAttempt.Ready) break; + c.Warn(decision == HwTestSavingAttempt.Retry + ? $"保存中の状態を作れなかった(撮影の直後に保存が終わっていた.試行 {attempt}).枚数を増やして再試行します" + : $"保存中の状態を作れなかった({attempt} 回試行).保存中に閉じる取り消しの検査を省き,保存後に閉じられることだけを確かめます"); + if (!WaitForSave(session, c, $"試行 {attempt}")) return; + if (decision == HwTestSavingAttempt.GiveUp) break; + } + + if (decision == HwTestSavingAttempt.Ready && !camera.IsSaving) { + // 連続撮影の直後の確認から閉じる処理を呼ぶまでの間に保存が終わった(ほぼ起きない) + c.Warn("保存中の状態を作れなかった(閉じる処理を呼ぶ直前に保存が終わった).保存中に閉じる取り消しの検査を省きます"); + decision = HwTestSavingAttempt.GiveUp; + if (!WaitForSave(session, c, "保存の完了")) return; + } + if (decision == HwTestSavingAttempt.Ready) { + if (!CheckCancelledClose(session, c, lightWasOpen)) return; + if (!WaitForSave(session, c, "保存中に閉じた後")) return; + VerifyShotFolder(session, c, "保存中に閉じた後", folder, count, 0); + } + + CheckFinalClose(session, c, "保存の完了後に閉じる"); + } + + /// 保存中に閉じる処理を呼び,取り消されたことを確かめる + /// 取り消された(後続の検査を続けられる)なら true.閉じてしまったら false + private static bool CheckCancelledClose(Session session, SelfTestCheck c, bool lightWasOpen) { + var form = session.Form; + var camera = session.Camera; + var stepsBefore = form.HwTestCloseSteps.Count; + SetStep(session, "保存中に閉じる処理(Form1_FormClosing.取り消されるはず)"); + var closed = RequestCloseObserved(session, stepsBefore, out var elapsed, out var error, out _, out var added); + // 表示は閉じる処理が同期に設定する.保存スレッドの投稿(保存の進み具合)で上書きされる前に読む(メッセージを処理する前) + var shown = form.HwTestMessage; + var savingAfter = camera.IsSaving; + + c.Info($"保存中に閉じる処理: {elapsed.TotalMilliseconds:0} ms,閉じると決まった: {(closed ? "はい" : "いいえ")},表示「{shown}」," + + $"進んだ後始末の段階: {(added.Count == 0 ? "なし" : string.Join(" → ", added))},直後も保存中: {(savingAfter ? "はい" : "いいえ")}"); + if (error != null) c.FailException("保存中に閉じる処理で例外", error); + var problems = HwTestJudge.CancelledCloseProblems(closed, shown, Form1.SavingOnCloseMessage, added.Count, + form.HwTestIsClosing, form.HwTestRefreshTimerRunning); + foreach (var p in problems) c.Fail($"保存中に閉じる処理: {p}"); + if (problems.Count == 0) c.Info($"保存中に閉じる処理は取り消されました(表示「{Form1.SavingOnCloseMessage}」,後始末は走っていません)"); + c.Expect(form.HwTestLightIsOpen == lightWasOpen, "保存中に閉じる処理の後に光源のポートの状態が変わりました(後始末が走った疑い)"); + return !closed; + } + + /// + /// 閉じる処理を呼び,閉じられること(所要時間・例外の有無・後始末の順序・閉じた後のフレーム)を確かめる(close-saving・close-calibrating 共通). + /// + private static void CheckFinalClose(Session session, SelfTestCheck c, string label) { + var form = session.Form; + if (!c.Expect(session.CameraConnected, $"{label}: カメラが接続されていません")) return; + var stepsBefore = form.HwTestCloseSteps.Count; + SetStep(session, $"{label}(Form1_FormClosing → ShutdownOnClose)"); + var closed = RequestCloseObserved(session, stepsBefore, out var elapsed, out var error, out var steps, out var added); + + c.Info($"{label}: 所要時間 {elapsed.TotalMilliseconds:0} ms,通過した段階: {string.Join(" → ", added)}"); + if (error != null) c.FailException($"{label}: 閉じる処理で例外", error); + c.Expect(closed, $"{label}: 閉じる処理が取り消されました(保存中と判定された.保存中: {(session.Camera.IsSaving ? "はい" : "いいえ")})"); + c.Expect(HwTestJudge.WithinLimit(elapsed, HwTestThresholds.CloseLimit), + $"{label}: 閉じる処理が上限(暫定 {HwTestThresholds.CloseLimit.TotalMilliseconds:0} ms)を超えました: {elapsed.TotalMilliseconds:0} ms"); + c.Expect(HwTestJudge.IsCloseSequenceSince(steps, stepsBefore), + $"{label}: 後始末の順序が不正です: {string.Join(" → ", added)}(期待 closing → timer → camera → light)"); + c.Expect(form.HwTestIsClosing, $"{label}: 閉じ始めのフラグが立っていません"); + c.Expect(!form.HwTestRefreshTimerRunning, $"{label}: 本モニタの再描画タイマーが止まっていません"); + c.Expect(!form.HwTestLightIsOpen, $"{label}: 閉じた後も光源のポートが開いています"); + + SetStep(session, $"{label}: 閉じた後にフレームが止まったかの確認"); + var afterStart = form.HwTestFrameCount; + SelfTestPump.PumpFor(HwTestThresholds.AfterCloseObserve); + var afterFrames = form.HwTestFrameCount - afterStart; + c.Expect(afterFrames <= HwTestThresholds.MaxFramesAfterClose, + $"{label}: 閉じた後もフレームが届いています: {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒で {afterFrames} フレーム(上限 暫定 {HwTestThresholds.MaxFramesAfterClose})", + $"{label}: 閉じた後 {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒間に届いたフレーム: {afterFrames}"); + } + + /// + /// 閉じる処理(HwTestRequestClose)を呼び,所要時間・例外・その回に進んだ後始末の段階を返す(CheckCancelledClose・CheckFinalClose 共通). + /// 後始末がカメラの切断まで進んだら session.CameraConnected を下ろす.メッセージは処理しない(直後の表示を呼び出し側が読めるように). + /// + /// 閉じる処理の前の後始末の段階の記録の件数 + /// 閉じる処理の後の後始末の段階の記録(全件) + /// steps のうち stepsBefore 以降(その回の閉じる処理の分) + /// 閉じる処理が閉じると決めたか(例外のときは false) + private static bool RequestCloseObserved(Session session, int stepsBefore, out TimeSpan elapsed, out Exception error, + out IReadOnlyList steps, out List added) { + var form = session.Form; + var sw = Stopwatch.StartNew(); + var closed = false; + error = null; + try { + closed = form.HwTestRequestClose(); + } catch (Exception ex) { + error = ex; + } + elapsed = sw.Elapsed; + steps = form.HwTestCloseSteps; + added = HwTestJudge.StepsSince(steps, stepsBefore); + if (added.Contains("camera")) session.CameraConnected = false; + return closed; + } + + // ================================================================================ + // close-calibrating + // ================================================================================ + + /// + /// 画面の状態を戻して再接続し(校正前の状態に戻る),校正中(CameraBase.IsCalibrating)になるのを待って閉じる処理を呼び, + /// すぐ戻ること(所要時間・例外の有無・後始末の順序・閉じた後のフレーム)を確かめる. + /// + private static void CheckCloseCalibrating(Session session, SelfTestCheck c) { + var form = session.Form; + if (session.CameraConnected) { + // 前の項目で閉じられなかった場合.閉じる処理を通さずに切断してから再接続する + SetStep(session, "切断(Disconnect)"); + form.HwTestDisconnectCamera(); + session.CameraConnected = false; + c.Info("前の項目で閉じられなかったカメラを切断しました"); + } + + SetStep(session, "画面の状態を接続直後に戻す(閉じ始めのフラグ・再描画タイマー・光源)"); + var lightOpen = form.HwTestResumeAfterClose(); + c.Info($"画面の状態を戻しました(光源のポート: {(lightOpen ? "開いて点灯" : "開けなかった")})"); + + SetStep(session, "Connect(再接続.校正前の状態に戻る)"); + var sw = Stopwatch.StartNew(); + var camera = form.HwTestConnect(session.Options.Camera); + var connectErrors = form.HwTestTakeConnectErrors(); + if (!c.Expect(camera != null, $"再接続の Connect() に失敗しました({string.Join(" / ", connectErrors)})")) return; + session.Camera = camera; + session.CameraConnected = true; + c.Info($"再接続の Connect(): {sw.ElapsedMilliseconds} ms(機種名 {camera.DeviceName},シリアル番号 {camera.SerialNumber}.ホワイトバランスはデバイス状態ファイルの値に戻る)"); + c.Expect(camera.SerialNumber == session.FirstSerial, + $"再接続したカメラのシリアル番号が 1 回目と異なります: {camera.SerialNumber}(1 回目 {session.FirstSerial})"); + + SetStep(session, "校正中(チャート検出 → ホワイトバランスの調整中)になるまでの待機"); + var calibrating = SelfTestPump.PumpUntil(() => camera.IsCalibrating || camera.HwTestIsCalibrated, HwTestThresholds.CalibratingWait); + var detectMs = HwTestJudge.TimestampDeltaMs(camera.HwTestFirstFrameAt, camera.HwTestChartDetectedAt, Stopwatch.Frequency); + if (!c.Expect(calibrating && camera.IsCalibrating, + calibrating + ? "校正中になる前に校正が完了しました(校正中に閉じる検査になりません)" + : $"{HwTestThresholds.CalibratingWait.TotalSeconds:0} 秒以内に校正中になりません({form.HwTestFrameCount} フレーム,最後の表示「{form.HwTestMessage}」.チャートを確認してください)")) return; + c.Info($"校正中になりました(最初のフレーム → チャート検出 {FormatMs(detectMs)})"); + + SetStep(session, "校正の途中まで進める"); + SelfTestPump.PumpFor(HwTestThresholds.CalibratingBeforeClose); + var stillCalibrating = camera.IsCalibrating; + if (stillCalibrating) { + c.Info($"閉じる処理の直前も校正中です(校正中になってから {HwTestThresholds.CalibratingBeforeClose.TotalMilliseconds:0} ms 後)"); + } else { + c.Warn("閉じる処理を呼ぶ前に校正が終わりました(校正中に閉じる検査になっていません.Calib/Frames を確認してください)"); + } + + CheckFinalClose(session, c, "校正中に閉じる"); + c.Info($"閉じた後の校正の状態: {(camera.IsCalibrating ? "校正中のまま止まった" : camera.HwTestIsCalibrated ? "校正済み" : "未校正")}(切断で校正の途中の状態は捨てられ,次の接続でやり直す)"); + } + } +} +#endif diff --git a/TIASshot/HwTest/HwTestRunner.Hang.cs b/TIASshot/HwTest/HwTestRunner.Hang.cs new file mode 100644 index 0000000..f72aa7d --- /dev/null +++ b/TIASshot/HwTest/HwTestRunner.Hang.cs @@ -0,0 +1,187 @@ +#if HWTEST +#if !SELFTEST +#error HWTEST は SELFTEST の部品(SelfTestReport)を使うため,SELFTEST と同時に定義すること +#endif +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +namespace TIASshot { + + /// + /// 実機テストのタイムアウト時に止まった場所を記録する(HWTEST 限定.本番・SELFTEST のみのビルドには含まれない). + /// ウォッチドッグが項目・全体の上限を超えたと判定したときだけ呼ばれ, + /// UI スレッド・保存スレッドのマネージドスタックを結果ファイルの詳細行に,自プロセスのミニダンプを作業フォルダに書く. + /// どちらも上限時間を設けた別スレッドで行い,取得に失敗・時間切れしてもウォッチドッグの流れ(光源のポートを閉じる → 終了コード 3)は止めない. + /// + internal static partial class HwTestRunner { + + /// + /// UI スレッドと保存スレッド(生きていれば)のマネージドスタックを取り,結果ファイルの詳細行(" - " の後ろ)にする行の並びを返す. + /// 失敗・時間切れの場合も理由を行にして返す(例外は漏らさない). + /// + private static List CaptureHangStacks(Session session) { + var lines = new List(); + AppendThreadStack(lines, "UI スレッド", session.UiThread); + Thread saveThread = null; + try { + saveThread = session.Camera?.HwTestSaveThread; + } catch (Exception ex) { + lines.Add($"保存スレッド: 参照を取れません: {ex.GetType().Name}: {ex.Message}"); + Debug.WriteLine($"[HWTEST] 保存スレッドの参照を取れません: {ex}"); + } + if (saveThread == null) { + lines.Add("保存スレッド: なし(まだ起動していない)"); + } else if (!saveThread.IsAlive) { + lines.Add("保存スレッド: 終了済み"); + } else { + AppendThreadStack(lines, "保存スレッド", saveThread); + } + return lines; + } + + /// + /// 1 スレッドのマネージドスタックを取る.取得は別スレッドで行い,HwTestThresholds.HangStackTimeout で打ち切る + /// (Suspend が安全点に届かない等でウォッチドッグ自体が止まらないため). + /// + private static void AppendThreadStack(List lines, string label, Thread target) { + if (target == null) { + lines.Add($"{label}: スレッドが記録されていません"); + return; + } + var name = $"{label}(ManagedThreadId {target.ManagedThreadId}{(string.IsNullOrEmpty(target.Name) ? "" : $",{target.Name}")})"; + if (!target.IsAlive) { + lines.Add($"{name}: 終了済みのためスタックを取れません"); + return; + } + string stack = null; + string error = null; + var helper = new Thread(() => { + try { + stack = CaptureStack(target); + } catch (Exception ex) { + error = $"{ex.GetType().Name}: {ex.Message}"; + Debug.WriteLine($"[HWTEST] {label}のスタックを取れません: {ex}"); + } + }) { IsBackground = true, Name = "HwTest.StackCapture" }; + helper.Start(); + if (!helper.Join(HwTestThresholds.HangStackTimeout)) { + lines.Add($"{name}: スタックの取得が {HwTestThresholds.HangStackTimeout.TotalSeconds:0} 秒以内に終わりません(Suspend が戻らない可能性.ダンプで確認してください)"); + return; + } + if (error != null) { + lines.Add($"{name}: スタックを取れません: {error}"); + return; + } + var frames = stack.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + lines.Add($"{name}のマネージドスタック({frames.Length} 行.上が最も内側.ネイティブのフレームは含まない):"); + foreach (var f in frames) lines.Add($" {f.Trim()}"); + } + + /// + /// 対象スレッドを一時停止してマネージドスタックを取り,必ず再開する. + /// .NET Framework 4.8 で他スレッドのスタックを取る唯一の手段のため,Obsolete の Thread.Suspend / Resume と + /// StackTrace(Thread, bool) を使う(HWTEST 限定.タイムアウト後にプロセスを終了する直前だけ呼ぶ). + /// + private static string CaptureStack(Thread target) { +#pragma warning disable 618 // Thread.Suspend / Resume・StackTrace(Thread, bool) は Obsolete(他スレッドのスタックを取る目的に限って使う) + target.Suspend(); + try { + return new StackTrace(target, true).ToString(); + } finally { + target.Resume(); + } +#pragma warning restore 618 + } + + /// + /// 自プロセスのミニダンプを作業フォルダ(HwTestJudge.HangDumpPath)に書き,パスと大きさ(書けなければ WARN)を結果ファイルに出す. + /// 種類(全メモリ/小さいダンプ/書かない)はプライベートメモリと空き容量から HwTestJudge.ChooseDumpKind で決める. + /// 書き出しは別スレッドで行い,HwTestThresholds.HangDumpTimeout で打ち切る(例外は漏らさない). + /// + private static void WriteHangDump(Session session) { + var report = session.Report; + string path; + HwTestDumpKind kind; + long privateBytes = -1; + long freeBytes = -1; + try { + path = HwTestJudge.HangDumpPath(session.WorkDir, DateTime.Now); + Directory.CreateDirectory(session.WorkDir); + using (var self = Process.GetCurrentProcess()) privateBytes = self.PrivateMemorySize64; + var root = Path.GetPathRoot(Path.GetFullPath(path)); + freeBytes = new DriveInfo(root).AvailableFreeSpace; + kind = HwTestJudge.ChooseDumpKind(privateBytes, freeBytes); + } catch (Exception ex) { + report.WriteLine($" WARN: ミニダンプの準備に失敗したため書きません: {SelfTestReport.DescribeException(ex)}"); + return; + } + if (kind == HwTestDumpKind.None) { + report.WriteLine($" WARN: 空き容量が足りないためミニダンプを書きません(空き {FormatBytes(freeBytes)},必要 {FormatBytes(HwTestThresholds.SmallDumpMinFreeBytes)} 以上)"); + return; + } + + string error = null; + var sw = Stopwatch.StartNew(); + var helper = new Thread(() => { + try { + WriteMiniDump(path, HwTestJudge.DumpTypeFlags(kind)); + } catch (Exception ex) { + error = SelfTestReport.DescribeException(ex); + Debug.WriteLine($"[HWTEST] ミニダンプを書けません: {path}: {ex}"); + } + }) { IsBackground = true, Name = "HwTest.MiniDump" }; + helper.Start(); + var kindText = kind == HwTestDumpKind.Full + ? "全メモリ" + : $"小さいダンプ(全メモリにしなかった理由: プライベートメモリ {FormatBytes(privateBytes)},空き {FormatBytes(freeBytes)})"; + if (!helper.Join(HwTestThresholds.HangDumpTimeout)) { + report.WriteLine($" WARN: ミニダンプの書き出しが {HwTestThresholds.HangDumpTimeout.TotalSeconds:0} 秒以内に終わりません({kindText}.途中までのファイルが残る可能性: {path})"); + return; + } + if (error != null) { + report.WriteLine($" WARN: ミニダンプを書けませんでした({kindText}): {path}: {error}"); + return; + } + long size = -1; + try { + size = new FileInfo(path).Length; + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { + Debug.WriteLine($"[HWTEST] ミニダンプの大きさを取れません: {path}: {ex.Message}"); + } + report.WriteLine($" - ミニダンプ: {path}({kindText},{(size >= 0 ? FormatBytes(size) : "大きさ不明")},{sw.ElapsedMilliseconds} ms." + + $"プライベートメモリ {FormatBytes(privateBytes)}.MINIDUMP_TYPE 0x{HwTestJudge.DumpTypeFlags(kind):X})"); + } + + /// dbghelp の MiniDumpWriteDump で自プロセスのダンプを書く(失敗は Win32Exception.途中まで書いたファイルは残す) + private static void WriteMiniDump(string path, uint dumpType) { + using (var self = Process.GetCurrentProcess()) + using (var stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) { + if (!NativeMethods.MiniDumpWriteDump(self.Handle, (uint)self.Id, stream.SafeFileHandle, dumpType, + IntPtr.Zero, IntPtr.Zero, IntPtr.Zero)) { + // MiniDumpWriteDump は失敗の理由を HRESULT として GetLastError で返す + var err = Marshal.GetLastWin32Error(); + throw new Win32Exception(err, $"MiniDumpWriteDump が失敗しました(0x{err:X8})"); + } + } + } + + private static string FormatBytes(long bytes) { + if (bytes < 0) return "不明"; + return $"{bytes / (1024.0 * 1024.0):0.0} MB"; + } + + private static class NativeMethods { + [DllImport("dbghelp.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool MiniDumpWriteDump(IntPtr hProcess, uint processId, SafeFileHandle hFile, uint dumpType, + IntPtr exceptionParam, IntPtr userStreamParam, IntPtr callbackParam); + } + } +} +#endif diff --git a/TIASshot/HwTest/HwTestRunner.cs b/TIASshot/HwTest/HwTestRunner.cs index 55a33c8..45633ab 100644 --- a/TIASshot/HwTest/HwTestRunner.cs +++ b/TIASshot/HwTest/HwTestRunner.cs @@ -26,19 +26,22 @@ /// reconnect: 切断した後に再接続でき,フレームが届く /// cleanup: 後始末(露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯はしない))と,設定・デバイス状態ファイルを書き換えていないこと /// + /// --with-chart を指定すると,上の preview〜reconnect の代わりにチャートが必要な次の項目を行う(校正は止めない.connect・cleanup は共通). + /// + /// calibrate: 実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値(tcc_srgb)に近い + /// shot: 単発・連続撮影で撮影画像・sRGB 変換画像・Info.csv が保存され,撮影時刻の間隔が設定どおり + /// close-saving: 保存中に閉じようとすると取り消され,保存後は閉じられる(後始末の順序) + /// close-calibrating: 再接続して校正中に閉じてもすぐ戻る + /// /// 保存先は一時フォルダ(%TEMP%\TIASshot-hwtest\work-*)に差し替え,同梱 config.xml・デバイス状態ファイル({DeviceName}.xml)・本番の保存先には書き込まない. - /// 終了コード: 0=全項目合格,1=不合格あり,2=実機テスト自体の異常(引数不正・指定機種が見つからない・未実装の指定等),3=タイムアウト. + /// 終了コード: 0=全項目合格,1=不合格あり,2=実機テスト自体の異常(引数不正・指定機種が見つからない等),3=タイムアウト. /// 【注意】判定のしきい値(HwTestThresholds)は暫定値.結果ファイルには必ず測定値を出す. /// - internal static class HwTestRunner { + internal static partial class HwTestRunner { /// 作業フォルダに書き出す一時設定ファイルの名前(セルフテストの config.selftest.xml と区別する) internal const string ConfigFileName = "config.hwtest.xml"; - private static readonly string[] CheckIds = { - "connect", "preview", "exposure-gain", "light", "close-preview", "reconnect", "cleanup", - }; - public static bool IsRequested(string[] args) { return HwTestOptions.IsRequested(args); } @@ -82,19 +85,15 @@ return HwTestExitCodes.Error; } report.WriteLine($" camera: {options.Camera}"); - if (options.WithChart) { - // フェーズ 2(calibrate・shot・close-saving・close-calibrating)は未実装.機材には触れずに終える - report.WriteLine(); - report.WriteLine($"[FAIL] with-chart: {HwTestOptions.FlagWithChart}(チャートが必要な検査.PLAN_06 フェーズ 2)は未実装です(カメラ・光源には触れていません)"); - report.WriteLine("RESULT: FAIL (not implemented: --with-chart)"); - return HwTestExitCodes.Error; - } + report.WriteLine($" checks: {string.Join(", ", HwTestJudge.CheckIds(options.WithChart))}" + + (options.WithChart ? "(チャートが必要な検査.チャート不要の検査は --with-chart なしで別に実行する)" : "")); report.WriteLine($" work dir: {workDir}"); - report.WriteLine($" thresholds (暫定): {DescribeThresholds()}"); + report.WriteLine($" thresholds (暫定): {DescribeThresholds(options.WithChart)}"); + report.WriteLine($" overall timeout: {HwTestJudge.OverallTimeout(options.WithChart).TotalSeconds:0} 秒"); var suppressCalibration = HwTestJudge.ShouldSuppressCalibration(options.WithChart); report.WriteLine(suppressCalibration ? " 校正: 停止(チャート不要の検査.チャートが写っても校正(チャート検出・ホワイトバランスの調整・変換行列の算出)は走らない)" - : " 校正: 有効"); + : " 校正: 有効(チャートが必要な検査.カメラの前に舌診チャートがはまっている前提.校正でカメラのホワイトバランスが変わるが,次の接続でデバイス状態ファイルの値に戻る)"); report.WriteLine(); var session = new Session { @@ -103,6 +102,7 @@ BaseDir = baseDir, WorkDir = workDir, CalibrationSuppressed = suppressCalibration, + UiThread = Thread.CurrentThread, }; // カメラの接続(コールバック開始)より前に設定する.static のため再接続で作り直したカメラにも効く CameraBase.HwTestCalibrationSuppressed = suppressCalibration; @@ -147,6 +147,7 @@ public string WorkDir; public string SaveRoot; public string UserSaveFolder; + public Thread UiThread; // Form1 が動くスレッド(Run を呼んだスレッド.タイムアウト時にスタックを取る) public Form1 Form; public CameraBase Camera; // 現在のカメラ @@ -190,7 +191,17 @@ return string.Join(" ", flags); } - private static string DescribeThresholds() { + private static string DescribeThresholds(bool withChart) { + if (withChart) { + return $"calibration wait<={HwTestThresholds.CalibrationWait.TotalSeconds:0}s, " + + $"color error (8bit, |measured-ref| per channel) mean<={HwTestThresholds.MaxCalibrationMeanError} " + + $"max (white patch excluded)<={HwTestThresholds.MaxCalibrationMaxError} white patch max<={HwTestThresholds.MaxCalibrationWhiteMaxError} (白飛びの調査までの暫定) (ΔE76・白飛びの割合は参考), " + + $"multi shot {HwTestThresholds.ShotMultiCount}x{HwTestThresholds.ShotMultiIntervalMs}ms interval tolerance<={HwTestThresholds.ShotIntervalToleranceMs}ms, " + + $"save wait<={HwTestThresholds.SaveWait.TotalSeconds:0}s, " + + $"close-saving shots {HwTestThresholds.CloseSavingShotCount} (retry {HwTestThresholds.CloseSavingRetryShotCount}) x0ms, " + + $"calibrating wait<={HwTestThresholds.CalibratingWait.TotalSeconds:0}s, close<={HwTestThresholds.CloseLimit.TotalMilliseconds:0}ms, " + + $"frames after close<={HwTestThresholds.MaxFramesAfterClose}"; + } return $"preview>={HwTestThresholds.MinPreviewFps}fps, " + $"exposure ratio down<={HwTestThresholds.ExposureLowerMaxRatio} up>={HwTestThresholds.ExposureRaiseMinRatio}, " + $"gain delta>={HwTestThresholds.MinBrightnessDelta} (one side ok), " @@ -232,16 +243,20 @@ itemElapsed = session.ItemWatch.Elapsed; limit = session.CurrentLimit; } - var overdue = HwTestJudge.FindOverdue(overall.Elapsed, HwTestThresholds.OverallTimeout, itemElapsed, limit); + var overallLimit = HwTestJudge.OverallTimeout(session.Options.WithChart); + var overdue = HwTestJudge.FindOverdue(overall.Elapsed, overallLimit, itemElapsed, limit); if (overdue == HwTestOverdue.None) continue; var report = session.Report; var reason = overdue == HwTestOverdue.Item ? $"項目 {id} が {limit.Value.TotalSeconds:0} 秒以内に終わりません" - : $"実機テスト全体が {HwTestThresholds.OverallTimeout.TotalSeconds:0} 秒以内に終わりません"; + : $"実機テスト全体が {overallLimit.TotalSeconds:0} 秒以内に終わりません"; var message = $"{reason}(実行中のステップ: {step ?? "不明"}.SDK 呼び出しのハング・Invoke の相互待機・モーダル表示の疑い)"; + // 止まった場所の記録: 結果ファイルを書く前に(状態が動く前に)UI スレッド・保存スレッドのマネージドスタックを取る + var stackLines = CaptureHangStacks(session); if (check != null) { check.Fail($"タイムアウト: {message}"); + foreach (var line in stackLines) check.Info(line); try { report.WriteCheck(check); } catch (InvalidOperationException ex) { @@ -251,9 +266,16 @@ } } else { report.WriteLine($"[FAIL] watchdog: {message}"); + foreach (var line in stackLines) report.WriteLine($" - {line}"); } + // ミニダンプ(自プロセス)を作業フォルダに書く.光源のポートを閉じる前に書く(止まった状態をなるべく崩さないため) + WriteHangDump(session); EmergencyCleanup(session); report.WriteLine($"RESULT: FAIL (timeout{(id != null ? $": {id}" : "")})"); + // タイムアウト時は作業フォルダ(ダンプ・撮影データ)を残す(Environment.Exit で CleanupWorkDir に届かないため,ここで残したことを出す) + if (HwTestJudge.ShouldKeepWorkDir(session.Options.Keep, HwTestExitCodes.Timeout) && Directory.Exists(session.WorkDir)) { + report.WriteLine($"work dir kept: {session.WorkDir}"); + } Environment.Exit(HwTestExitCodes.Timeout); } }) { @@ -366,7 +388,17 @@ HwTestThresholds.ConnectTimeout, c => CheckConnect(session, c)); checks.Add(connect); - if (connect.Status == SelfTestStatus.Pass) { + var ids = HwTestJudge.CheckIds(session.Options.WithChart); + if (connect.Status == SelfTestStatus.Pass && session.Options.WithChart) { + checks.Add(RunCheck(session, "calibrate", "実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値に近い", + HwTestThresholds.CalibrateTimeout, c => CheckCalibrate(session, c))); + checks.Add(RunCheck(session, "shot", "単発・連続撮影で撮影画像・sRGB 変換画像・Info.csv が保存され,撮影時刻の間隔が設定どおり", + HwTestThresholds.ShotTimeout, c => CheckShot(session, c))); + checks.Add(RunCheck(session, "close-saving", "保存中に閉じようとすると取り消され,保存の完了後は閉じられる", + HwTestThresholds.CloseSavingTimeout, c => CheckCloseSaving(session, c))); + checks.Add(RunCheck(session, "close-calibrating", "再接続して校正中に閉じる処理を呼んでもすぐ戻る", + HwTestThresholds.CloseCalibratingTimeout, c => CheckCloseCalibrating(session, c))); + } else if (connect.Status == SelfTestStatus.Pass) { checks.Add(RunCheck(session, "preview", "プレビューが流れる(フレームレートの下限)", HwTestThresholds.PreviewTimeout, c => CheckPreview(session, c))); checks.Add(RunCheck(session, "exposure-gain", "露光・ゲインの上げ下げがフレームの平均輝度に期待どおりの向きで効く", @@ -381,7 +413,7 @@ var reason = session.CameraNotFound ? $"指定した機種({session.Options.Camera})が見つからないため実行しません" : "前提の connect が失敗したため実行しません"; - foreach (var id in CheckIds.Skip(1).Take(5)) checks.Add(SkipCheck(session, id, reason)); + foreach (var id in ids.Skip(1).Take(ids.Length - 2)) checks.Add(SkipCheck(session, id, reason)); } checks.Add(RunCheck(session, "cleanup", "後始末(露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる)と,設定・デバイス状態ファイル・本番の保存先を書き換えていない", @@ -872,7 +904,7 @@ c.Expect(closed, "閉じる処理が取り消されました(保存中と判定された)"); c.Expect(HwTestJudge.WithinLimit(elapsed, HwTestThresholds.CloseLimit), $"閉じる処理が上限(暫定 {HwTestThresholds.CloseLimit.TotalMilliseconds:0} ms)を超えました: {elapsed.TotalMilliseconds:0} ms"); - c.Expect(steps.SequenceEqual(new[] { "closing", "timer", "camera", "light" }), + c.Expect(steps.SequenceEqual(HwTestJudge.CloseSequence), $"後始末の順序が不正です: {string.Join(" → ", steps)}(期待 closing → timer → camera → light)"); c.Expect(form.HwTestIsClosing, "閉じ始めのフラグが立っていません"); c.Expect(!form.HwTestRefreshTimerRunning, "本モニタの再描画タイマーが止まっていません"); @@ -970,6 +1002,9 @@ } else { c.Info("露光・ゲイン: 変更なし(または検査中に元に戻し済み)"); } + if (session.Options.WithChart) { + c.Info("ホワイトバランス: 校正で変わったまま戻していません(カメラ側の値だけが変わり,次の接続でデバイス状態ファイルの値に戻ります.デバイス状態ファイルには書き込みません)"); + } if (session.CameraConnected) { SetStep(session, "カメラ切断"); diff --git a/TIASshot/SelfTest/PatchColorError.cs b/TIASshot/SelfTest/PatchColorError.cs new file mode 100644 index 0000000..5c3c935 --- /dev/null +++ b/TIASshot/SelfTest/PatchColorError.cs @@ -0,0 +1,222 @@ +#if SELFTEST +using System; +using OpenCvSharp; + +namespace TIASshot { + + /// + /// 色票の測定値と参照値(tcc_srgb)の差の集計結果(SELFTEST 限定.セルフテストと実機テストで共通). + /// + internal struct PatchColorErrorStats { + /// 色票の数(行数) + public int Patches; + /// チャネルごとの差の絶対値の平均(8bit 階調.全色票 × 3 チャネルの平均) + public double MeanAbs; + /// チャネルごとの差の絶対値の最大(8bit 階調) + public double MaxAbs; + /// 最大の差が出た色票(0 始まり.無ければ -1) + public int MaxAbsPatch; + /// 色差 ΔE(CIE76.sRGB(D65)→ CIELAB)の平均(参考) + public double MeanDeltaE; + /// 色差 ΔE(CIE76)の最大(参考) + public double MaxDeltaE; + /// ΔE の最大が出た色票(0 始まり.無ければ -1) + public int MaxDeltaEPatch; + } + + /// + /// 色票の測定値と参照値の差を求める純粋関数(SELFTEST 限定.セルフテストの calib-save と実機テストの calibrate から使う.層①でテストする). + /// 値の並びは OpenCV の画像と tcc_srgb.csv に合わせて 1 行 = 1 色票,列 0 = B・列 1 = G・列 2 = R(8bit 階調 0〜255). + /// + internal static class PatchColorError { + + /// + /// 測定値と参照値の差を集計する.差の絶対値の平均・最大(チャネルごと)と,参考の ΔE(CIE76)の平均・最大を返す. + /// 平均の分母は 色票の数 × 3(セルフテストの従来の計算と同じ順序・同じ値). + /// NaN を含む場合,平均・最大は NaN になる(最大の色票は NaN が出た色票). + /// + /// 測定値(n×3.BGR) + /// 参照値(n×3.BGR) + /// 引数が null の場合 + /// 形状が n×3 でない・両者の行数が違う・0 行の場合 + public static PatchColorErrorStats Compare(double[,] measuredBgr, double[,] referenceBgr) { + if (measuredBgr == null) throw new ArgumentNullException(nameof(measuredBgr)); + if (referenceBgr == null) throw new ArgumentNullException(nameof(referenceBgr)); + if (measuredBgr.GetLength(1) != 3 || referenceBgr.GetLength(1) != 3) { + throw new ArgumentException($"色票の値は n×3(BGR)にしてください(測定 {measuredBgr.GetLength(0)}x{measuredBgr.GetLength(1)},参照 {referenceBgr.GetLength(0)}x{referenceBgr.GetLength(1)})"); + } + var n = measuredBgr.GetLength(0); + if (n == 0 || referenceBgr.GetLength(0) != n) { + throw new ArgumentException($"色票の数が不正です(測定 {n},参照 {referenceBgr.GetLength(0)})"); + } + + double sumErr = 0, maxErr = 0, sumDe = 0, maxDe = 0; + int maxErrPatch = -1, maxDePatch = -1; + for (var i = 0; i < n; i++) { + for (var j = 0; j < 3; j++) { + var err = Math.Abs(measuredBgr[i, j] - referenceBgr[i, j]); + sumErr += err; + if (maxErrPatch < 0 || err > maxErr || double.IsNaN(err)) { // NaN は最大を NaN にする(Math.Max と同じ扱い.誤差を見逃さない) + maxErr = err; + maxErrPatch = i; + } + } + var de = DeltaE76(measuredBgr[i, 0], measuredBgr[i, 1], measuredBgr[i, 2], + referenceBgr[i, 0], referenceBgr[i, 1], referenceBgr[i, 2]); + sumDe += de; + if (maxDePatch < 0 || de > maxDe || double.IsNaN(de)) { + maxDe = de; + maxDePatch = i; + } + } + return new PatchColorErrorStats { + Patches = n, + MeanAbs = sumErr / (n * 3), + MaxAbs = maxErr, + MaxAbsPatch = maxErrPatch, + MeanDeltaE = sumDe / n, + MaxDeltaE = maxDe, + MaxDeltaEPatch = maxDePatch, + }; + } + + /// + /// 1 つの色票のチャネルごとの差の絶対値の最大(8bit 階調).NaN を含む場合は NaN. + /// + /// 引数が null の場合 + /// 形状が n×3 でない・両者の行数が違う場合 + /// patch が範囲外の場合 + public static double PatchMaxAbs(double[,] measuredBgr, double[,] referenceBgr, int patch) { + CheckShape(measuredBgr, referenceBgr); + if (patch < 0 || patch >= measuredBgr.GetLength(0)) throw new ArgumentOutOfRangeException(nameof(patch), patch, "色票の番号が範囲外です"); + var max = 0.0; + for (var j = 0; j < 3; j++) { + var err = Math.Abs(measuredBgr[patch, j] - referenceBgr[patch, j]); + if (double.IsNaN(err)) return double.NaN; + if (err > max) max = err; + } + return max; + } + + /// + /// 指定した色票を除いた全色票のチャネルごとの差の絶対値の最大(8bit 階調).NaN を含む場合は NaN. + /// 除いた後に色票が残らない場合(1 行で除外した場合)は NaN. + /// + /// 除く色票(0 始まり.範囲外なら何も除かない) + /// 最大が出た色票(0 始まり.無ければ -1) + /// 引数が null の場合 + /// 形状が n×3 でない・両者の行数が違う場合 + public static double MaxAbsExcluding(double[,] measuredBgr, double[,] referenceBgr, int excludedPatch, out int maxPatch) { + CheckShape(measuredBgr, referenceBgr); + maxPatch = -1; + var max = double.NaN; + for (var i = 0; i < measuredBgr.GetLength(0); i++) { + if (i == excludedPatch) continue; + var err = PatchMaxAbs(measuredBgr, referenceBgr, i); + if (maxPatch < 0 || err > max || double.IsNaN(err)) { + max = err; + maxPatch = i; + if (double.IsNaN(err)) return double.NaN; + } + } + return max; + } + + /// + /// 参照値から白色票を決める(BGR の和が最も大きい色票.同じ値なら先の色票.tcc_srgb.csv では 13 番=0 始まりの 12). + /// 参照 CSV の行の並びに依存させず,番号をマジックナンバーにしないため. + /// + /// 白色票(0 始まり) + /// 引数が null の場合 + /// 形状が n×3 でない・0 行・NaN を含む場合 + public static int WhitePatchIndex(double[,] referenceBgr) { + if (referenceBgr == null) throw new ArgumentNullException(nameof(referenceBgr)); + if (referenceBgr.GetLength(1) != 3 || referenceBgr.GetLength(0) == 0) { + throw new ArgumentException($"参照値は n×3(BGR.1 行以上)にしてください({referenceBgr.GetLength(0)}x{referenceBgr.GetLength(1)})"); + } + var best = -1; + var bestSum = 0.0; + for (var i = 0; i < referenceBgr.GetLength(0); i++) { + var sum = referenceBgr[i, 0] + referenceBgr[i, 1] + referenceBgr[i, 2]; + if (double.IsNaN(sum)) throw new ArgumentException($"参照値に NaN があります({i + 1} 番)"); + if (best < 0 || sum > bestSum) { + best = i; + bestSum = sum; + } + } + return best; + } + + private static void CheckShape(double[,] measuredBgr, double[,] referenceBgr) { + if (measuredBgr == null) throw new ArgumentNullException(nameof(measuredBgr)); + if (referenceBgr == null) throw new ArgumentNullException(nameof(referenceBgr)); + if (measuredBgr.GetLength(1) != 3 || referenceBgr.GetLength(1) != 3 || measuredBgr.GetLength(0) != referenceBgr.GetLength(0)) { + throw new ArgumentException($"色票の値は同じ行数の n×3(BGR)にしてください(測定 {measuredBgr.GetLength(0)}x{measuredBgr.GetLength(1)},参照 {referenceBgr.GetLength(0)}x{referenceBgr.GetLength(1)})"); + } + } + + /// + /// 2 色の色差 ΔE(CIE76.CIELAB でのユークリッド距離).入力は sRGB の 8bit 階調(BGR の順). + /// + public static double DeltaE76(double b1, double g1, double r1, double b2, double g2, double r2) { + SrgbToLab(r1, g1, b1, out var l1, out var a1, out var bb1); + SrgbToLab(r2, g2, b2, out var l2, out var a2, out var bb2); + var dl = l1 - l2; + var da = a1 - a2; + var db = bb1 - bb2; + return Math.Sqrt(dl * dl + da * da + db * db); + } + + /// + /// sRGB(8bit 階調 0〜255.IEC 61966-2-1.白色点 D65)を CIELAB(D65)に変換する. + /// 0〜255 の外の値(変換行列の結果は範囲外になりうる)は丸めずに,ガンマの式を符号つきで延長して扱う + /// (範囲外の誤差を切り捨てて小さく見せないため). + /// + public static void SrgbToLab(double r, double g, double b, out double l, out double a, out double bLab) { + var rl = SrgbToLinear(r / 255.0); + var gl = SrgbToLinear(g / 255.0); + var bl = SrgbToLinear(b / 255.0); + // sRGB(D65)→ XYZ(IEC 61966-2-1 の行列) + var x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl; + var y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl; + var z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl; + // D65 の白色点で正規化 + var fx = LabF(x / 0.95047); + var fy = LabF(y / 1.00000); + var fz = LabF(z / 1.08883); + l = 116.0 * fy - 16.0; + a = 500.0 * (fx - fy); + bLab = 200.0 * (fy - fz); + } + + /// + /// 24×3(CV_64FC1)などの n×3 の Mat を配列にする(tcc_srgb.csv・TCC_sRGB{CN}.csv の読み込み結果用). + /// + /// n×3 の 1 チャネルの Mat でない場合 + public static double[,] ToArray(Mat m) { + if (m == null) throw new ArgumentNullException(nameof(m)); + if (m.Cols != 3 || m.Channels() != 1) throw new ArgumentException($"n×3 の 1 チャネルの Mat ではありません({m.Rows}x{m.Cols},{m.Channels()} チャネル)"); + var result = new double[m.Rows, 3]; + for (var i = 0; i < m.Rows; i++) { + for (var j = 0; j < 3; j++) { + result[i, j] = m.At(i, j); + } + } + return result; + } + + private static double SrgbToLinear(double v) { + var sign = v < 0 ? -1.0 : 1.0; + var abs = Math.Abs(v); + var lin = abs <= 0.04045 ? abs / 12.92 : Math.Pow((abs + 0.055) / 1.055, 2.4); + return sign * lin; + } + + private static double LabF(double t) { + const double delta = 6.0 / 29.0; + if (t > delta * delta * delta) return Math.Pow(t, 1.0 / 3.0); + return t / (3 * delta * delta) + 4.0 / 29.0; + } + } +} +#endif diff --git a/TIASshot/SelfTest/SelfTestRunner.cs b/TIASshot/SelfTest/SelfTestRunner.cs index c9139fe..d390adc 100644 --- a/TIASshot/SelfTest/SelfTestRunner.cs +++ b/TIASshot/SelfTest/SelfTestRunner.cs @@ -291,7 +291,7 @@ } } - private static bool IsUnder(string path, string root) { + internal static bool IsUnder(string path, string root) { if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(root)) return false; var p = Path.GetFullPath(path).TrimEnd('\\') + "\\"; var r = Path.GetFullPath(root).TrimEnd('\\') + "\\"; @@ -720,12 +720,7 @@ using (var converted = IoUtil.LoadMatFromCsv(Path.Combine(folder, convertedFile))) { if (!c.Expect(converted != null && converted.Rows == 24 && converted.Cols == 3, $"{convertedFile} を 24x3 として読み込めません")) continue; - var maxErr = 0.0; - for (var i = 0; i < 24; i++) { - for (var j = 0; j < 3; j++) { - maxErr = Math.Max(maxErr, Math.Abs(converted.At(i, j) - tccSrgb.At(i, j))); - } - } + var maxErr = PatchColorError.Compare(PatchColorError.ToArray(converted), PatchColorError.ToArray(tccSrgb)).MaxAbs; c.Expect(maxErr < 5.0, $"{ch} 次元: 色票の変換値が参照値から離れすぎ(最大誤差 {maxErr:0.00})", $"{ch} 次元: 変換行列(有限値)と色票の変換値(参照値との最大誤差 {maxErr:0.00})"); } @@ -750,20 +745,19 @@ if (!CheckImageFile(c, path, frameSize)) continue; // 変換画像上の色票が参照値(tcc_srgb)に戻っているか(JPEG 圧縮の誤差を許容) using (var img = Cv2.ImRead(path, ImreadModes.Color)) { - double sumErr = 0, maxErr = 0; + var measured = new double[24, 3]; for (var i = 0; i < 24; i++) { var roi = SelfTestChart.PatchRoi(i); var inner = new Rect(roi.X + 10, roi.Y + 10, roi.Width - 20, roi.Height - 20); using (var patch = new Mat(img, inner)) { var mean = Cv2.Mean(patch); - for (var j = 0; j < 3; j++) { - var err = Math.Abs(mean[j] - tccSrgb.At(i, j)); - sumErr += err; - maxErr = Math.Max(maxErr, err); - } + for (var j = 0; j < 3; j++) measured[i, j] = mean[j]; } } - var meanErr = sumErr / 72; + // 誤差の計算は実機テスト(calibrate)と共通(PatchColorError.平均は 24 色票 × 3 チャネル = 72 の平均) + var stats = PatchColorError.Compare(measured, PatchColorError.ToArray(tccSrgb)); + var meanErr = stats.MeanAbs; + var maxErr = stats.MaxAbs; c.Expect(meanErr < 6.0 && maxErr < 15.0, $"{Path.GetFileName(path)}: 色票が参照値に戻っていない(平均誤差 {meanErr:0.00},最大 {maxErr:0.00})", $"{Path.GetFileName(path)}: 保存・読み戻し OK,色票の参照値との誤差 平均 {meanErr:0.00} / 最大 {maxErr:0.00}"); diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index 4be5f51..4baefff 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -175,11 +175,16 @@ + + + + + diff --git a/TIASshot/UI/Form1.HwTest.cs b/TIASshot/UI/Form1.HwTest.cs index 84f28d2..7631ca4 100644 --- a/TIASshot/UI/Form1.HwTest.cs +++ b/TIASshot/UI/Form1.HwTest.cs @@ -176,6 +176,54 @@ /// 本モニタ(picDisplay)に表示中の画像 internal Image HwTestDisplayedImage => picDisplay.Image; + /// + /// 閉じる処理で後始末まで済んだ後に,次の検査のために画面の状態を接続直後に戻す(閉じる処理を 2 回以上検査するため). + /// 閉じ始めのフラグを下ろし(以降の ShowMessage・EnableShots を通常どおり効かせる),撮影ボタンを無効にし, + /// 本モニタの再描画タイマーを動かし直し,光源のポートを開き直して点灯する(StartSession と同じ状態. + /// StartSession を 2 回呼ぶとタイマーの Tick の登録と副モニタが重複するため,ここで必要な分だけ戻す). + /// カメラは接続しない(呼び出し側が HwTestConnect で接続する). + /// + /// 光源のポートを開き直せたら true + internal bool HwTestResumeAfterClose() { + _isClosing = false; + EnableShots(false); + _mainRefreshTimer.Start(); + // 開いたままのポートを開き直すと SerialPort が例外を投げるため,閉じているときだけ開く + if (_light.IsOpen) return true; + return HwTestReopenLight(); + } + + // ================================================================================ + // 撮影 + // ================================================================================ + + /// データ名(txtDataName.保存フォルダ名の一部)を設定する + internal void HwTestSetDataName(string dataName) { + txtDataName.Text = dataName; + } + + /// 連続撮影の枚数・間隔(ms)の入力欄を設定する + internal void HwTestSetMultiShots(int numShots, int intervalMs) { + txtNumMultiShots.Text = numShots.ToString(System.Globalization.CultureInfo.InvariantCulture); + txtMultiShotsInterval.Text = intervalMs.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + /// 撮影ボタン(単発・連続)が両方とも有効か + internal bool HwTestShotsEnabled => btnShotOne.Enabled && btnShotMulti.Enabled; + + /// ステータス表示(txtMessage)の現在値 + internal string HwTestMessage => txtMessage.Text; + + /// 単発撮影のボタンを押す(通常の操作と同じ処理.撮影は UI スレッドで同期に行われ,保存は保存スレッドで続く) + internal void HwTestClickShotOne() { + btnShot1_Click(btnShotOne, EventArgs.Empty); + } + + /// 連続撮影のボタンを押す(通常の操作と同じ処理) + internal void HwTestClickShotMulti() { + btnShotMulti_Click(btnShotMulti, EventArgs.Empty); + } + // ================================================================================ // フレームの観測 // ================================================================================