diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index 0e54a64..b6ff5fa 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -76,6 +76,20 @@ public string ErrorMsg { get; protected set; } public bool IsCalibrating => _calibrating > 0; +#if HWTEST + /// + /// 実機テスト(HWTEST 限定)のチャート不要の検査の間,フレームコールバックでの校正処理 + /// (チャート検出・ホワイトバランスの調整・変換行列の算出)を止めるか. + /// カメラの前にチャート(または白板)をはめたまま露光・ゲイン・光源を検査するため, + /// HwTestRunner が接続前に設定する(再接続で作り直したカメラにも効くよう static にする). + /// プレビュー表示(ShowImage)は止めない.UI スレッドが書き,コールバックスレッドが読むため volatile. + /// + internal static volatile bool HwTestCalibrationSuppressed = false; + + /// 校正済みか(実機テストの観測用.HWTEST 限定) + internal bool HwTestIsCalibrated => _calibrated; +#endif + // 派生クラスで使用するメンバ protected Form1 _form; protected int _calibrating = 0; @@ -450,6 +464,10 @@ /// /// 処理対象 Mat(前処理済み) protected void ProcessCalibrationFrame(Mat imgt) { +#if HWTEST + // 実機テストのチャート不要の検査では校正を走らせない(本番・SELFTEST のみのビルドには含まれない) + if (HwTestCalibrationSuppressed) return; +#endif try { if (_calibrating > 0) { var whitePatch = Cv2.Mean(imgt, _chartMasks[12]); diff --git a/TIASshot/Cameras/IScam.HwTest.cs b/TIASshot/Cameras/IScam.HwTest.cs new file mode 100644 index 0000000..b55b072 --- /dev/null +++ b/TIASshot/Cameras/IScam.HwTest.cs @@ -0,0 +1,112 @@ +#if HWTEST +using System; +using TIS.Imaging; + +namespace TIASshot { + + /// + /// IScam の実機テスト用の口(HWTEST 限定).SDK を呼ぶだけの薄い処理に留める. + /// 露光・ゲインはカメラ(ドライバ)上の値だけを変え,デバイス状態ファイル({DeviceName}.xml)には書き込まない. + /// 本番の撮影・校正が使う VCDRangeProperty(_exposure・_gain)の読み書きには触れない. + /// + /// + /// 露光・ゲインは VCDAbsoluteValueProperty(絶対値.double)で読み書きする. + /// 根拠: + /// + /// IC Imaging Control 3.5 のヘルプ(dotnethelp.chm「VCD Properties」tech_VCDProperties.htm): + /// 「The VCDAbsoluteValueProperty allows the property to be accessed in terms of a physical dimension with high accuracy」. + /// 一方 VCDRangeProperty は「This interface does not provide a meaning for the value」で,値は Int32(参照 DLL のリフレクションで + /// VCDRangeProperty.Value・RangeMin・RangeMax は System.Int32,VCDAbsoluteValueProperty.Value・RangeMin・RangeMax・Default は System.Double, + /// DimType は System.String). + /// 取得は VCDPropertyItems.Find<VCDAbsoluteValueProperty>(VCDGUIDs.VCDID_Exposure, VCDGUIDs.VCDElement_Value) + /// (ヘルプ meth_descVCDPropertyItems_Find.htm.該当する口が無ければ null を返す). + /// デバイス状態ファイル(DFK33UX178.xml)の Exposure/Value は interface GUID {99B44940-BFE1-4083-ADA1-BE703F4B8E08} + /// (= VCDGUIDs.VCDInterface_AbsoluteValue.リフレクションで確認)で value="0.016667"(1/60 秒)と保存されている. + /// つまり起動時の露光は絶対値で決まっており,VCDRangeProperty の整数値(-5 と読める.値 n ≒ 2^n 秒の段)では表せない. + /// 前回の実機テストは整数値 -5 を「元の値」として書き戻したため 1/32 秒(元の 1.875 倍)になり,読み戻しもどちらも -5 で見逃した. + /// 単位: 露光の絶対値は秒(状態ファイルの 0.016667 が 1/60 秒に当たる.実測の平均輝度の比 0.936 ≒ 60/64 とも一致). + /// 実行時の DimType も結果ファイルに出す. + /// + /// ゲインも同じ状態ファイルで絶対値の口(...8E08.value="0.000000")で保存されているため,絶対値が取れれば絶対値で読み書きする. + /// 絶対値の口が無い機種に限り VCDRangeProperty の整数値で読み書きする(整数値で読んだ値をそのまま書き戻すので整数値の範囲では元に戻る). + /// 露光は絶対値の口が無いと元の値へ正確に戻せない(上記の不具合)ため,口が無ければ例外にする(exposure-gain が FAIL になる). + /// + internal partial class IScam : IHwTestAdjustable { + + public string HwTestExposureUnit { + get { + var abs = FindAbsolute(VCDGUIDs.VCDID_Exposure); + return abs != null + ? $"秒(VCDAbsoluteValueProperty の Exposure.DimType「{abs.DimType}」)" + : "(VCDAbsoluteValueProperty の Exposure が取得できません)"; + } + } + + /// + /// IScam の露光(VCDAbsoluteValueProperty.秒)は線形の量だが,検査では 2^n 秒のきりのよい段で動かす + /// (基準探しの前に最も近い 2^n 秒に合わせ,1 段 = 時間で 2 倍・半分).元の値への復元は元の絶対値で行う. + /// 本番の撮影・校正の露光の決め方には関係しない(検査で動かす量にだけ使う). + /// + public HwTestValueScale HwTestExposureScale => HwTestValueScale.Log2Step; + + public string HwTestGainUnit { + get { + var abs = FindAbsolute(VCDGUIDs.VCDID_Gain); + return abs != null + ? $"VCDAbsoluteValueProperty の Gain(DimType「{abs.DimType}」)" + : "VCDRangeProperty の値(Gain.絶対値の口が無いため整数値)"; + } + } + + public HwTestSetting HwTestGetExposure() { + var abs = RequireExposureAbsolute(); + return new HwTestSetting(abs.Value, abs.RangeMin, abs.RangeMax); + } + + public void HwTestSetExposure(double value) { + var abs = RequireExposureAbsolute(); + abs.Value = HwTestJudge.Clamp(value, abs.RangeMin, abs.RangeMax); + } + + public HwTestSetting HwTestGetGain() { + var abs = FindAbsolute(VCDGUIDs.VCDID_Gain); + if (abs != null) return new HwTestSetting(abs.Value, abs.RangeMin, abs.RangeMax); + var range = RequireRange(_gain, "Gain"); + return new HwTestSetting(range.Value, range.RangeMin, range.RangeMax); + } + + public void HwTestSetGain(double value) { + var abs = FindAbsolute(VCDGUIDs.VCDID_Gain); + if (abs != null) { + abs.Value = HwTestJudge.Clamp(value, abs.RangeMin, abs.RangeMax); + return; + } + var range = RequireRange(_gain, "Gain"); + range.Value = (int)Math.Round(HwTestJudge.Clamp(value, range.RangeMin, range.RangeMax)); + } + + /// + /// 値の要素(VCDElement_Value)の絶対値の口を探す(無ければ null).呼ぶたびに探す + /// (再接続で古い口を使わないため.検査の 1 段あたり数回なので負荷は問題にならない). + /// + private VCDAbsoluteValueProperty FindAbsolute(Guid itemId) { + if (_ic == null || !_ic.DeviceValid) return null; + return _ic.VCDPropertyItems.Find(itemId, VCDGUIDs.VCDElement_Value); + } + + private VCDAbsoluteValueProperty RequireExposureAbsolute() { + if (_ic == null || !_ic.DeviceValid) throw new InvalidOperationException("Exposure プロパティを取得できません(未接続)"); + var abs = FindAbsolute(VCDGUIDs.VCDID_Exposure); + if (abs == null) { + throw new InvalidOperationException("Exposure の絶対値(VCDAbsoluteValueProperty)を取得できません(整数値では元の露光へ正確に戻せないため検査しません)"); + } + return abs; + } + + private static VCDRangeProperty RequireRange(VCDRangeProperty property, string name) { + if (property == null) throw new InvalidOperationException($"{name} プロパティを取得できていません(未接続)"); + return property; + } + } +} +#endif diff --git a/TIASshot/Cameras/IScam.cs b/TIASshot/Cameras/IScam.cs index 3718187..3186a14 100644 --- a/TIASshot/Cameras/IScam.cs +++ b/TIASshot/Cameras/IScam.cs @@ -14,7 +14,7 @@ using System.IO; namespace TIASshot { - internal class IScam : CameraBase { + internal partial class IScam : CameraBase { ICImagingControl _ic; VCDRangeProperty _brightness; @@ -53,7 +53,10 @@ ErrorMsg = $"{configFile}が見つかりません"; return false; } - _ic.LoadDeviceStateFromFile(configFile, true); + // 列挙で見つけたデバイスを先に開き,デバイス状態ファイルは設定の適用だけに使う(OpenDevice: false). + // OpenDevice: true だとファイルに書かれた個体(unique_name のシリアル)を開こうとするため, + // 同機種でもシリアルが違う個体では ArgumentException になる + if (!OpenDevice(configFile)) return false; if (!_ic.DeviceValid) { ErrorMsg = $"設定ファイル{configFile}の読み込みに失敗しました"; return false; @@ -91,6 +94,48 @@ } /// + /// DeviceName に対応するデバイスを列挙から選んで開き,デバイス状態ファイルの設定を適用する. + /// SDK の例外は外へ出さず,ErrorMsg に理由を設定して false を返す(起動時に次のデバイスを試せるようにする). + /// + /// デバイス状態ファイル({DeviceName}.xml) + /// 開いて設定を適用できたら true + private bool OpenDevice(string configFile) { + string deviceToOpen; + try { + deviceToOpen = IScamDeviceSelector.FindDeviceName(_ic.Devices.Select(d => d.Name), DeviceName); + } catch (Exception ex) { + Debug.WriteLine($"[IScam] デバイスの列挙に失敗: {ex}"); + ErrorMsg = $"ImagingSource カメラの一覧を取得できませんでした: {ex.Message}"; + return false; + } + if (deviceToOpen == null) { + ErrorMsg = $"{DeviceName}に対応する ImagingSource カメラが見つかりません"; + return false; + } + + try { + _ic.Device = deviceToOpen; + } catch (Exception ex) { + Debug.WriteLine($"[IScam] デバイス {deviceToOpen} を開けません: {ex}"); + ErrorMsg = $"カメラ{deviceToOpen}を開けませんでした: {ex.Message}"; + return false; + } + if (!_ic.DeviceValid) { + ErrorMsg = $"カメラ{deviceToOpen}を開けませんでした"; + return false; + } + + try { + _ic.LoadDeviceStateFromFile(configFile, false); + } catch (Exception ex) { + Debug.WriteLine($"[IScam] デバイス状態ファイル {configFile} をデバイス {deviceToOpen} に適用できません: {ex}"); + ErrorMsg = $"設定ファイル{configFile}をカメラ{deviceToOpen}に適用できませんでした: {ex.Message}"; + return false; + } + return true; + } + + /// /// IScam切断 /// public override void Disconnect() { diff --git a/TIASshot/Cameras/IScamDeviceSelector.cs b/TIASshot/Cameras/IScamDeviceSelector.cs new file mode 100644 index 0000000..407f2af --- /dev/null +++ b/TIASshot/Cameras/IScamDeviceSelector.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace TIASshot { + /// + /// ImagingSource カメラの接続先デバイスの選択(IScam から切り出した判断ロジック.SDK に依存しない). + /// IScam の DeviceName は IC Imaging Control の列挙名(例: "DFK 33UX178")から空白を除いたキー(例: "DFK33UX178")で, + /// デバイス状態ファイル名({DeviceName}.xml)と config.xml の機種別設定のキーにも使う. + /// + internal static class IScamDeviceSelector { + + /// + /// 列挙名から DeviceName(デバイス状態ファイル名・設定キー)に使うキーを作る(空白を除く). + /// + /// IC Imaging Control の列挙名(Device.Name) + /// 空白を除いた名前.null のときは null + internal static string ToDeviceKey(string deviceName) { + return deviceName?.Replace(" ", ""); + } + + /// + /// 列挙されたデバイス名の中から,キーが deviceKey に一致する最初の名前を選ぶ(大文字小文字を区別する). + /// 返した名前を ICImagingControl.Device に設定して開く. + /// + /// IC Imaging Control が列挙したデバイス名(Devices[].Name).null 可 + /// 探す DeviceName(空白を除いたキー) + /// 開くデバイスの列挙名.見つからなければ null + internal static string FindDeviceName(IEnumerable deviceNames, string deviceKey) { + if (deviceNames == null || string.IsNullOrEmpty(deviceKey)) return null; + foreach (var name in deviceNames) { + if (string.IsNullOrEmpty(name)) continue; + if (string.Equals(ToDeviceKey(name), deviceKey, StringComparison.Ordinal)) return name; + } + return null; + } + } +} diff --git a/TIASshot/Cameras/Lucam.HwTest.cs b/TIASshot/Cameras/Lucam.HwTest.cs new file mode 100644 index 0000000..2dd40f3 --- /dev/null +++ b/TIASshot/Cameras/Lucam.HwTest.cs @@ -0,0 +1,68 @@ +#if HWTEST +using System; +using Lumenera.USB; + +namespace TIASshot { + + /// + /// Lucam の実機テスト用の口(HWTEST 限定).SDK を呼ぶだけの薄い処理に留める. + /// 露光(ms)・ゲイン(倍率)は LucamGetProperty / LucamSetProperty の float の実数値そのもの(整数への丸めは無い)で読み書きするため, + /// 最初に読んだ値をそのまま書き戻せば元の値に戻る(HwTestRunner.RestoreSettings が IScam と同じく相対誤差 1% で読み戻しを確かめる). + /// + internal partial class Lucam : IHwTestAdjustable { + + /// 接続されている Lumenera カメラの台数(SDK が無い・列挙に失敗した場合は例外) + internal static int HwTestCountCameras() { + return dll.LucamNumCameras(); + } + + public string HwTestExposureUnit => "ms(LucamProperty.EXPOSURE)"; + + /// Lucam の露光(LucamProperty.EXPOSURE)は ms 単位の線形の量(検査では ×0.5 / ×2 で 1 段動かす) + public HwTestValueScale HwTestExposureScale => HwTestValueScale.Linear; + + public string HwTestGainUnit => "倍率(LucamProperty.GAIN)"; + + public HwTestSetting HwTestGetExposure() { + return GetSetting(dll.LucamProperty.EXPOSURE); + } + + public void HwTestSetExposure(double value) { + var range = GetSetting(dll.LucamProperty.EXPOSURE); + var v = (float)HwTestJudge.Clamp(value, range.Min, range.Max); + // 以降の SetCameraParam(校正中のホワイトバランス反映)で元に戻されないよう,保持値も更新する + _snap.Exposure = v; + SetProperty(dll.LucamProperty.EXPOSURE, v); + } + + public HwTestSetting HwTestGetGain() { + return GetSetting(dll.LucamProperty.GAIN); + } + + public void HwTestSetGain(double value) { + var range = GetSetting(dll.LucamProperty.GAIN); + var v = (float)HwTestJudge.Clamp(value, range.Min, range.Max); + _snap.Gain = v; + SetProperty(dll.LucamProperty.GAIN, v); + } + + private HwTestSetting GetSetting(dll.LucamProperty property) { + if (_hCam == IntPtr.Zero) throw new InvalidOperationException("Lucam が接続されていません"); + if (!dll.LucamGetProperty(_hCam, property, out float value, out dll.LucamPropertyFlag _)) { + throw new InvalidOperationException($"LucamGetProperty({property}) に失敗しました({dll.LucamGetLastErrorForCamera(_hCam)})"); + } + if (!dll.LucamPropertyRange(_hCam, property, out float min, out float max, out float _, out dll.LucamPropertyFlag _)) { + throw new InvalidOperationException($"LucamPropertyRange({property}) に失敗しました({dll.LucamGetLastErrorForCamera(_hCam)})"); + } + return new HwTestSetting(value, min, max); + } + + private void SetProperty(dll.LucamProperty property, float value) { + if (_hCam == IntPtr.Zero) throw new InvalidOperationException("Lucam が接続されていません"); + if (!dll.LucamSetProperty(_hCam, property, value, dll.LucamPropertyFlag.NONE)) { + throw new InvalidOperationException($"LucamSetProperty({property}, {value}) に失敗しました({dll.LucamGetLastErrorForCamera(_hCam)})"); + } + } + } +} +#endif diff --git a/TIASshot/Cameras/Lucam.cs b/TIASshot/Cameras/Lucam.cs index 2f4dcf3..fc1ee49 100644 --- a/TIASshot/Cameras/Lucam.cs +++ b/TIASshot/Cameras/Lucam.cs @@ -27,7 +27,7 @@ /// /// Lumeneraカメラクラス /// - internal class Lucam : CameraBase { + internal partial class Lucam : CameraBase { // 光源の輝度設定 // 連続撮影のマルチスレッド化 diff --git a/TIASshot/HwTest/HwTestJudge.cs b/TIASshot/HwTest/HwTestJudge.cs new file mode 100644 index 0000000..7f30d50 --- /dev/null +++ b/TIASshot/HwTest/HwTestJudge.cs @@ -0,0 +1,734 @@ +#if HWTEST +using System; +using System.Collections.Generic; + +namespace TIASshot { + + /// + /// 実機テストの判定しきい値と手順のパラメータ(HWTEST 限定). + /// 【暫定値】判定しきい値は最初の実機実行で実測してから決め直す(PLAN_06). + /// 結果ファイルには必ず測定値を出すため,暫定値で FAIL しても測定値から決め直せる. + /// + internal static class HwTestThresholds { + + // ---- 判定しきい値(暫定) ---- + + /// preview: フレームレートの下限(fps).DFK 33UX178 の実測 16.86〜17.71 fps に対して余裕を持たせた値 + public const double MinPreviewFps = 10.0; + + /// + /// exposure-gain: 露光を 1 段(時間で半分)下げたときの平均輝度の比(下げた / 基準)の上限. + /// 消灯値(黒の下限)は light より前のため未測定で,単純比で見る(黒の下限の分だけ比は 1 に寄る.DFK 33UX178 の実測は 1/60→1/64 s で 0.94 ≒ 60/64) + /// + public const double ExposureLowerMaxRatio = 0.8; + + /// exposure-gain: 露光を 1 段(時間で 2 倍)上げたときの平均輝度の比(上げた / 基準)の下限(単純比) + public const double ExposureRaiseMinRatio = 1.25; + + /// exposure-gain: ゲインを上げ下げしたときに期待する平均輝度の変化量の下限(8bit 階調) + public const double MinBrightnessDelta = 3.0; + + /// 飽和とみなす画素値(8bit 階調).画素のいずれかのチャネルがこれ以上なら飽和画素として数える + public const int SaturatedPixelLevel = 254; + + /// + /// exposure-gain: 飽和画素の割合(0〜1)がこれを超えたフレームは飽和とみなす(暫定 1%). + /// 平均輝度だけでは明るい色票の飽和を見逃すため(DFK 33UX178 の実測: 平均 227.8 で明るい色票が飽和) + /// + public const double MaxSaturatedFraction = 0.01; + + /// exposure-gain: これ以下の平均輝度は真っ暗とみなし,下げる向きの確認を省く(8bit 階調) + public const double DarkLevel = 5.0; + + /// exposure-gain: 基準にする平均輝度の中間の範囲の下限(8bit 階調.暫定).これ未満は暗すぎるとして露光を上げる向きに探す + public const double MidBrightnessLow = 30.0; + + /// exposure-gain: 基準にする平均輝度の中間の範囲の上限(8bit 階調.暫定).これを超えると明るすぎるとして露光を下げる向きに探す + public const double MidBrightnessHigh = 220.0; + + /// exposure-gain: 元の値に戻した後の平均輝度と最初の平均輝度の差の許容(超えたら WARN.判定には使わない) + public const double RestoreTolerance = 10.0; + + /// exposure-gain: 露光・ゲインを元の値に戻したときの読み戻しの相対誤差の許容(超えたら FAIL) + public const double RestoreRelativeTolerance = 0.01; + + /// + /// exposure-gain: ゲインの読み戻しの絶対誤差の許容(設定範囲の幅に対する割合). + /// ゲインの元の値は 0(dB)のことがあり,相対誤差だけでは判定できないため + /// + public const double GainRestoreRangeFraction = 0.001; + + /// light: 点灯時と消灯時の平均輝度の差の下限(8bit 階調).DFK 33UX178 の実測は約 196 + public const double MinLightDelta = 100.0; + + /// close-preview: 閉じる処理(後始末)の所要時間の上限.DFK 33UX178 の実測は 144 ms + public static readonly TimeSpan CloseLimit = TimeSpan.FromMilliseconds(1000); + + /// close-preview: 閉じた後の観測時間内に届いてよいフレーム数の上限(停止処理中に流れていた分) + public const int MaxFramesAfterClose = 2; + + /// reconnect: 再接続後に届くべきフレーム数の下限 + public const int ReconnectMinFrames = 5; + + // ---- 手順のパラメータ ---- + + /// 最初のフレームが届くまでの待機の上限 + public static readonly TimeSpan FirstFrameTimeout = TimeSpan.FromSeconds(10); + + /// preview: フレームレートを測る前の慣らし時間 + public static readonly TimeSpan PreviewWarmup = TimeSpan.FromSeconds(1); + + /// preview: フレームレートを測る時間 + public static readonly TimeSpan PreviewMeasure = TimeSpan.FromSeconds(3); + + /// 設定(露光・ゲイン・光源)を変えた後,フレームに反映されるのを待つ時間 + public static readonly TimeSpan SettleTime = TimeSpan.FromMilliseconds(1500); + + /// 平均輝度の 1 回の測定に使うフレーム数 + public const int SamplesPerMeasurement = 8; + + /// 平均輝度の 1 回の測定の待機の上限 + public static readonly TimeSpan SampleTimeout = TimeSpan.FromSeconds(15); + + /// 平均輝度を求めるときの間引き(縦横とも何画素ごとに読むか) + public const int PixelStep = 4; + + /// exposure-gain: 露光が 2^n の段(HwTestValueScale.Log2Step)のとき 1 段で動かす段数(±1 = 時間で 2 倍・半分) + public const double ExposureLogStep = 1.0; + + /// exposure-gain: 基準の明るさを中間に寄せるために露光を動かす段数の上限(元の設定の測定は含めない) + public const int BaselineSearchMaxSteps = 12; + + /// exposure-gain: 露光が線形の量(HwTestValueScale.Linear)のとき下げる倍率 + public const double ExposureDownFactor = 0.5; + + /// exposure-gain: 露光が線形の量(HwTestValueScale.Linear)のとき上げる倍率 + public const double ExposureUpFactor = 2.0; + + /// exposure-gain: ゲインを上げ下げする量(設定範囲の幅に対する割合) + public const double GainStepFraction = 0.1; + + /// close-preview: 閉じた後にフレームが止まったかを見る時間 + public static readonly TimeSpan AfterCloseObserve = TimeSpan.FromSeconds(1); + + /// reconnect: 再接続後のフレームの待機の上限 + public static readonly TimeSpan ReconnectFrameTimeout = TimeSpan.FromSeconds(15); + + // ---- タイムアウト(各項目・全体) ---- + + public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(60); + public static readonly TimeSpan PreviewTimeout = TimeSpan.FromSeconds(60); + public static readonly TimeSpan ExposureGainTimeout = TimeSpan.FromSeconds(180); + public static readonly TimeSpan LightTimeout = TimeSpan.FromSeconds(90); + public static readonly TimeSpan ClosePreviewTimeout = TimeSpan.FromSeconds(30); + public static readonly TimeSpan ReconnectTimeout = TimeSpan.FromSeconds(60); + public static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(30); + + /// 実機テスト全体の上限(各項目の上限の合計より長くする) + public static readonly TimeSpan OverallTimeout = TimeSpan.FromMinutes(10); + } + + /// ウォッチドッグの判定結果(HWTEST 限定) + internal enum HwTestOverdue { + /// 上限内 + None, + /// 実行中の項目が項目の上限を超えた + Item, + /// 全体の上限を超えた + Overall, + } + + /// + /// 露光・ゲインの上げ下げに対する平均輝度の変化の向きの判定結果(HWTEST 限定). + /// + internal sealed class HwTestDirectionResult { + /// 下げる向きを確認したか(設定が変わらなかった・元が真っ暗・下げた側が飽和の場合は確認しない) + public bool LowerChecked; + /// 下げたときに十分暗くなったか + public bool LowerOk; + /// 上げる向きを確認したか(設定が変わらなかった・元が飽和・上げた側が飽和の場合は確認しない) + public bool RaiseChecked; + /// 上げたときに十分明るくなったか + public bool RaiseOk; + /// 合格か(基準(HwTestDirectionCriteria.RequireAllChecked)に従う) + public bool Pass; + /// 判定の説明(結果ファイルに出す) + public string Summary; + } + + /// + /// 露光・ゲインの上げ下げの判定の基準(HWTEST 限定). + /// 比で見る(UseRatio)場合は 下げた / 基準 ≦ LowerLimit・上げた / 基準 ≧ RaiseLimit, + /// 差で見る場合は 基準 − 下げた ≧ LowerLimit・上げた − 基準 ≧ RaiseLimit を期待どおりとする. + /// + internal sealed class HwTestDirectionCriteria { + /// 比で見るか(false なら差で見る) + public bool UseRatio; + /// 下げる向きの限度(比なら上限,差なら下限) + public double LowerLimit; + /// 上げる向きの限度(比・差とも下限) + public double RaiseLimit; + /// 基準の平均輝度がこれ以下なら真っ暗とみなし,下げる向き(比なら上げる向きも)を確認しない + public double DarkLevel; + /// 飽和画素の割合がこれを超えたら飽和とみなす + public double MaxSaturatedFraction; + /// + /// true: 確認した向きがすべて期待どおりで合格(少なくとも一方を確認できること). + /// false: 一方の向きだけでも期待どおりなら合格(範囲の端で変化が小さくなりうるゲイン用) + /// + public bool RequireAllChecked; + } + + /// 1 フレームの測定値(平均輝度と飽和画素の割合)(HWTEST 限定) + internal struct HwTestFrameStats { + /// 平均輝度(0〜255) + public double Mean; + /// 飽和画素の割合(0〜1) + public double SaturatedFraction; + + public HwTestFrameStats(double mean, double saturatedFraction) { + Mean = mean; + SaturatedFraction = saturatedFraction; + } + } + + /// 複数フレームをまとめた 1 回の測定値(HWTEST 限定) + internal struct HwTestMeasurement { + /// 平均輝度の平均(フレームが無ければ NaN) + public double Mean; + /// 飽和画素の割合の最大(各フレームのうち最も飽和したもの.判定に使う.フレームが無ければ NaN) + public double SaturatedFraction; + /// 飽和画素の割合の平均(参考.フレームが無ければ NaN) + public double MeanSaturatedFraction; + /// フレーム数 + public int Count; + + /// 測定できなかった(フレームが無い)測定値 + public static readonly HwTestMeasurement NotMeasured = new HwTestMeasurement { + Mean = double.NaN, SaturatedFraction = double.NaN, MeanSaturatedFraction = double.NaN, Count = 0, + }; + } + + /// 基準の明るさ探し(exposure-gain)の 1 段ごとの判断(HWTEST 限定) + internal enum HwTestBaselineSearchDecision { + /// 平均輝度が中間の範囲に入り,飽和もしていない(この設定を基準にする) + Found, + /// 同じ向きにもう 1 段動かす + Continue, + /// 測定できなかった(平均輝度が NaN) + StopNotMeasured, + /// 設定が範囲の端で変わらなくなった + StopRangeEnd, + /// 段数の上限に達した + StopMaxSteps, + /// 中間の範囲を飛び越えた(明るすぎ → 暗すぎ,またはその逆) + StopOvershoot, + } + + /// 基準の明るさ探しの 1 段(設定値とその平均輝度・飽和画素の割合)(HWTEST 限定) + internal struct HwTestBaselineSample { + public double Setting; + public double Mean; + public double SaturatedFraction; + + public HwTestBaselineSample(double setting, double mean, double saturatedFraction) { + Setting = setting; + Mean = mean; + SaturatedFraction = saturatedFraction; + } + } + + /// + /// 実機テストの判断ロジック(HWTEST 限定.SDK・UI・スレッドに依存しない純粋関数.層①でテストする). + /// + internal static class HwTestJudge { + + /// フレームレート(fps).時間が 0 以下なら 0 + public static double FrameRate(long frames, TimeSpan elapsed) { + if (elapsed <= TimeSpan.Zero || frames <= 0) return 0; + return frames / elapsed.TotalSeconds; + } + + /// フレームレートが下限以上か + public static bool MeetsMinFrameRate(double fps, double minFps) { + return !double.IsNaN(fps) && fps >= minFps; + } + + /// + /// 現在値に倍率を掛けた設定値を範囲内に収めて返す(露光の上げ下げに使う). + /// 現在値が 0 以下で倍率が 1 を超える場合は,範囲の幅の 5% だけ最小値から上げた値を返す(0 に掛けても変わらないため). + /// + /// 最小値が最大値より大きい・非有限値を含む場合 + public static double ScaleSetting(double current, double min, double max, double factor) { + ValidateRange(current, min, max); + if (!IsFinite(factor) || factor <= 0) { + throw new ArgumentException($"倍率が不正です: {factor}", nameof(factor)); + } + double target; + if (current > 0) { + target = current * factor; + } else if (factor > 1) { + target = min + (max - min) * 0.05; + } else { + target = current; + } + return Clamp(target, min, max); + } + + /// + /// 露光を 1 段動かした設定値を範囲内に収めて返す. + /// 2^n の段(Log2Step)は現在値に最も近い 2^n(log2 を四捨五入)から logStep 段動かした 2^(n±logStep)(時間で 2 倍・半分), + /// 線形の量(Linear)は ScaleSetting で downFactor / upFactor を掛ける. + /// + /// 正なら上げる(明るくする)向き,負なら下げる(暗くする)向き.0 は不可 + /// direction が 0・範囲が不正・非有限値を含む・未知の種類の場合,Log2Step で現在値が 0 以下の場合 + public static double NextExposure(double current, double min, double max, HwTestValueScale scale, int direction, + double logStep, double downFactor, double upFactor) { + ValidateRange(current, min, max); + if (direction == 0) throw new ArgumentException("向きが 0 です", nameof(direction)); + switch (scale) { + case HwTestValueScale.Log2Step: + if (!IsFinite(logStep) || logStep <= 0) { + throw new ArgumentException($"段の量が不正です: {logStep}", nameof(logStep)); + } + var n = NearestLog2Exponent(current); + return Clamp(Math.Pow(2, n + (direction > 0 ? logStep : -logStep)), min, max); + case HwTestValueScale.Linear: + return ScaleSetting(current, min, max, direction > 0 ? upFactor : downFactor); + default: + throw new ArgumentException($"未知の値の種類です: {scale}", nameof(scale)); + } + } + + /// + /// 基準探しの前に露光をきりのよい段に合わせた値を返す. + /// 2^n の段(Log2Step)は現在値に最も近い 2^n(log2 を四捨五入).それが範囲外なら範囲内で最も近い 2^n, + /// 範囲内に 2^n が 1 つも無ければ現在値をそのまま返す.線形の量(Linear)は現在値をそのまま返す(合わせない). + /// + /// 範囲が不正・非有限値を含む・未知の種類の場合,Log2Step で現在値が 0 以下の場合 + public static double AlignExposure(double current, double min, double max, HwTestValueScale scale) { + ValidateRange(current, min, max); + switch (scale) { + case HwTestValueScale.Linear: + return current; + case HwTestValueScale.Log2Step: + var n = NearestLog2Exponent(current); + if (max <= 0) return current; + // 範囲の端がちょうど 2^n のとき log の丸め誤差で 1 段ずれないよう,わずかに内側へ寄せる + var nMax = Math.Floor(Math.Log(max, 2) + 1e-9); + var nMin = min > 0 ? Math.Ceiling(Math.Log(min, 2) - 1e-9) : double.NegativeInfinity; + if (nMin > nMax) return current; // 範囲内に 2^n が無い + if (n > nMax) n = nMax; + if (n < nMin) n = nMin; + return Clamp(Math.Pow(2, n), min, max); + default: + throw new ArgumentException($"未知の値の種類です: {scale}", nameof(scale)); + } + } + + /// 値に最も近い 2^n の n(log2 の四捨五入.読み戻しの微小な丸めに影響されない) + private static double NearestLog2Exponent(double value) { + if (value <= 0) throw new ArgumentException($"2^n の段にできない値です(0 以下): {value}", nameof(value)); + return Math.Round(Math.Log(value, 2), MidpointRounding.AwayFromZero); + } + + /// 平均輝度が中間の範囲(low 以上 high 以下)に入っているか.NaN は false + public static bool IsMidBrightness(double mean, double low, double high) { + return !double.IsNaN(mean) && mean >= low && mean <= high; + } + + /// 飽和画素の割合がしきい値を超えているか(飽和とみなすか).NaN(測定できない)は false + public static bool IsSaturated(double saturatedFraction, double maxSaturatedFraction) { + return !double.IsNaN(saturatedFraction) && saturatedFraction > maxSaturatedFraction; + } + + /// 基準にできる明るさか(平均輝度が中間の範囲に入り,かつ飽和画素の割合がしきい値以下) + public static bool IsBaselineCandidate(double mean, double saturatedFraction, double low, double high, double maxSaturatedFraction) { + return IsMidBrightness(mean, low, high) && !double.IsNaN(saturatedFraction) + && !IsSaturated(saturatedFraction, maxSaturatedFraction); + } + + /// + /// 基準の明るさを中間に寄せるために露光を動かす向き.high を超える・飽和している(飽和画素の割合がしきい値超え)なら -1(下げる), + /// low 未満なら +1(上げる),基準にできる明るさ・測定できない(NaN)なら 0(動かさない). + /// + public static int BaselineSearchDirection(double mean, double saturatedFraction, double low, double high, double maxSaturatedFraction) { + if (double.IsNaN(mean)) return 0; + if (mean > high || IsSaturated(saturatedFraction, maxSaturatedFraction)) return -1; + if (mean < low) return 1; + return 0; + } + + /// + /// 基準の明るさ探しで 1 段動かして測った後の判断. + /// 基準にできる明るさ(中間の範囲かつ飽和していない)になれば Found. + /// 測定できない・設定が変わらない(範囲の端)・中間の範囲を飛び越えた・段数の上限に達したら Stop*. + /// それ以外は Continue(同じ向きにもう 1 段). + /// + /// 動かした後の平均輝度 + /// 動かした後の飽和画素の割合 + /// 探している向き(-1: 下げる,+1: 上げる) + /// 動かした設定が実際に変わったか(読み戻しの比較) + /// これまでに動かした段数(今回を含む.1 以上) + /// 段数の上限 + public static HwTestBaselineSearchDecision DecideBaselineSearch(double mean, double saturatedFraction, int direction, bool settingChanged, + int stepsDone, int maxSteps, double low, double high, double maxSaturatedFraction) { + if (!settingChanged) return HwTestBaselineSearchDecision.StopRangeEnd; + if (double.IsNaN(mean) || double.IsNaN(saturatedFraction)) return HwTestBaselineSearchDecision.StopNotMeasured; + if (IsBaselineCandidate(mean, saturatedFraction, low, high, maxSaturatedFraction)) return HwTestBaselineSearchDecision.Found; + var tooBright = mean > high || IsSaturated(saturatedFraction, maxSaturatedFraction); + if ((direction < 0 && mean < low) || (direction > 0 && tooBright)) return HwTestBaselineSearchDecision.StopOvershoot; + if (stepsDone >= maxSteps) return HwTestBaselineSearchDecision.StopMaxSteps; + return HwTestBaselineSearchDecision.Continue; + } + + /// + /// 基準の明るさ探しで中間に寄せられなかったときに基準にする段を選ぶ. + /// 飽和していない段を飽和している段より優先し,その中で平均輝度が中間の範囲に最も近い段(同じ近さなら先の段). + /// 測定できなかった(平均輝度が NaN)段は選ばない.選べる段が無ければ -1. + /// + public static int ChooseBaselineIndex(IReadOnlyList samples, double low, double high, double maxSaturatedFraction) { + if (samples == null) return -1; + var best = -1; + var bestSaturated = true; + var bestDistance = double.PositiveInfinity; + for (var i = 0; i < samples.Count; i++) { + var mean = samples[i].Mean; + if (double.IsNaN(mean)) continue; + var saturated = IsSaturated(samples[i].SaturatedFraction, maxSaturatedFraction); + var distance = mean < low ? low - mean : mean > high ? mean - high : 0; + var better = best < 0 + || (bestSaturated && !saturated) + || (bestSaturated == saturated && distance < bestDistance); + if (better) { + best = i; + bestSaturated = saturated; + bestDistance = distance; + } + } + return best; + } + + /// + /// 現在値に範囲の幅の割合を足した設定値を範囲内に収めて返す(ゲインの上げ下げに使う.負の割合で下げる). + /// + /// 最小値が最大値より大きい・非有限値を含む場合 + public static double StepSetting(double current, double min, double max, double fractionOfRange) { + ValidateRange(current, min, max); + if (!IsFinite(fractionOfRange)) { + throw new ArgumentException($"割合が不正です: {fractionOfRange}", nameof(fractionOfRange)); + } + return Clamp(current + (max - min) * fractionOfRange, min, max); + } + + /// 設定値が変わったか(カメラから読み戻した値どうしの比較.整数値の丸めを考慮して相対誤差で見る) + public static bool IsChanged(double before, double after) { + var scale = Math.Max(1e-6, Math.Max(Math.Abs(before), Math.Abs(after))); + return Math.Abs(after - before) / scale > 1e-4; + } + + /// + /// 設定値が期待した値と同じか(元の値に戻したときの読み戻しの確認). + /// 差が「相対誤差の許容 × 大きい方の絶対値」と「絶対誤差の許容」の大きい方以内なら同じとみなす. + /// NaN を含む場合は false. + /// + /// 期待した値(元の値) + /// 読み戻した値 + /// 相対誤差の許容(0 以上.例 0.01 = 1%) + /// 絶対誤差の許容(0 以上.元の値が 0 になりうる設定用) + public static bool IsSameSetting(double expected, double actual, double relativeTolerance, double absoluteTolerance) { + if (double.IsNaN(expected) || double.IsNaN(actual)) return false; + var tolerance = Math.Max(Math.Max(0, relativeTolerance) * Math.Max(Math.Abs(expected), Math.Abs(actual)), Math.Max(0, absoluteTolerance)); + return Math.Abs(actual - expected) <= tolerance; + } + + /// + /// 複数フレームの測定値をまとめる(平均輝度は平均,飽和画素の割合は最大(判定用)と平均(参考)).空なら NotMeasured. + /// + public static HwTestMeasurement Summarize(IReadOnlyList frames) { + if (frames == null || frames.Count == 0) return HwTestMeasurement.NotMeasured; + double sumMean = 0, sumSat = 0, maxSat = double.NegativeInfinity; + foreach (var f in frames) { + sumMean += f.Mean; + sumSat += f.SaturatedFraction; + if (f.SaturatedFraction > maxSat) maxSat = f.SaturatedFraction; + } + return new HwTestMeasurement { + Mean = sumMean / frames.Count, + SaturatedFraction = maxSat, + MeanSaturatedFraction = sumSat / frames.Count, + Count = frames.Count, + }; + } + + /// + /// 画素データの平均輝度(先頭 3 チャネル(BGR)の平均.1〜2 チャネルの形式は先頭チャネル)と, + /// 飽和画素(読んだチャネルのいずれかが saturationLevel 以上の画素)の割合を求める. + /// rows は「間引いて読んだ行」を詰めて並べたもの(1 行 rowStride バイト,rowCount 行). + /// 各行の中は pixelStep 画素ごとに読む. + /// + /// 間引いた行を詰めた画素データ + /// 行数 + /// 1 行の画素数 + /// rows 内の 1 行のバイト数(width * bytesPerPixel 以上) + /// 1 画素のバイト数(1〜4) + /// 行の中で何画素ごとに読むか(1 以上) + /// 飽和とみなす画素値(0〜255) + /// 平均輝度(0〜255)と飽和画素の割合(0〜1) + /// 引数の組み合わせが不正な場合 + public static HwTestFrameStats FrameStats(byte[] rows, int rowCount, int width, int rowStride, int bytesPerPixel, int pixelStep, + int saturationLevel) { + if (rows == null) throw new ArgumentNullException(nameof(rows)); + if (rowCount <= 0 || width <= 0) throw new ArgumentException($"画素がありません({width}x{rowCount})"); + if (bytesPerPixel < 1 || bytesPerPixel > 4) throw new ArgumentException($"1 画素のバイト数が不正です: {bytesPerPixel}", nameof(bytesPerPixel)); + if (pixelStep < 1) throw new ArgumentException($"間引きが不正です: {pixelStep}", nameof(pixelStep)); + if (saturationLevel < 0 || saturationLevel > 255) throw new ArgumentException($"飽和とみなす画素値が不正です: {saturationLevel}", nameof(saturationLevel)); + if (rowStride < width * bytesPerPixel) throw new ArgumentException($"1 行のバイト数が不足しています: {rowStride} < {width * bytesPerPixel}", nameof(rowStride)); + if ((long)rowStride * rowCount > rows.Length) throw new ArgumentException($"画素データが不足しています: {rows.Length} < {(long)rowStride * rowCount}", nameof(rows)); + + var channels = bytesPerPixel >= 3 ? 3 : 1; + long sum = 0; + long count = 0; + long pixels = 0; + long saturated = 0; + for (var r = 0; r < rowCount; r++) { + var rowStart = r * rowStride; + for (var x = 0; x < width; x += pixelStep) { + var p = rowStart + x * bytesPerPixel; + var isSaturated = false; + for (var c = 0; c < channels; c++) { + var v = rows[p + c]; + sum += v; + if (v >= saturationLevel) isSaturated = true; + } + count += channels; + pixels++; + if (isSaturated) saturated++; + } + } + return new HwTestFrameStats((double)sum / count, (double)saturated / pixels); + } + + /// 露光の上げ下げの判定基準(比で見る.確認した向きはすべて期待どおりであること) + public static HwTestDirectionCriteria ExposureCriteria() { + return new HwTestDirectionCriteria { + UseRatio = true, + LowerLimit = HwTestThresholds.ExposureLowerMaxRatio, + RaiseLimit = HwTestThresholds.ExposureRaiseMinRatio, + DarkLevel = HwTestThresholds.DarkLevel, + MaxSaturatedFraction = HwTestThresholds.MaxSaturatedFraction, + RequireAllChecked = true, + }; + } + + /// + /// ゲインの上げ下げの判定基準(差で見る.一方の向きだけでも期待どおりなら合格). + /// 下げる側は範囲の下端にかかって変化が小さいことがあるため(DFK 33UX178 の実測: 10→0 で差 -3.2) + /// + public static HwTestDirectionCriteria GainCriteria() { + return new HwTestDirectionCriteria { + UseRatio = false, + LowerLimit = HwTestThresholds.MinBrightnessDelta, + RaiseLimit = HwTestThresholds.MinBrightnessDelta, + DarkLevel = HwTestThresholds.DarkLevel, + MaxSaturatedFraction = HwTestThresholds.MaxSaturatedFraction, + RequireAllChecked = false, + }; + } + + /// + /// 設定を下げた・上げたときの平均輝度の変化を判定する. + /// 下げる向きは「設定が実際に変わった(loweredApplied)かつ測定でき,基準が真っ暗でなく,下げた側が飽和していない」とき確認する. + /// 上げる向きは「設定が実際に変わった(raisedApplied)かつ測定でき,基準が飽和していない(飽和画素の割合がしきい値以下)」とき確認する + /// (比で見る場合は基準が真っ暗でないことも要る).ただし上げた側が飽和している場合は,比・差にかかわらず確認しない + /// (飽和した画素は明るさの変化を正しく表さず,期待どおりに見えても頭打ちで偶然越えただけのことがあるため). + /// 合否は criteria.RequireAllChecked に従う(true: 確認した向きがすべて期待どおりで,少なくとも一方を確認できた. + /// false: 少なくとも一方の向きが期待どおり). + /// + /// criteria が null の場合 + public static HwTestDirectionResult JudgeDirection(HwTestMeasurement baseline, HwTestMeasurement lowered, HwTestMeasurement raised, + bool loweredApplied, bool raisedApplied, HwTestDirectionCriteria criteria) { + if (criteria == null) throw new ArgumentNullException(nameof(criteria)); + var b = baseline.Mean; + var baselineMeasured = !double.IsNaN(b); + var baselineDark = baselineMeasured && b <= criteria.DarkLevel; + var baselineSaturated = IsSaturated(baseline.SaturatedFraction, criteria.MaxSaturatedFraction); + + var result = new HwTestDirectionResult(); + var lowerMeasurable = baselineMeasured && loweredApplied && !double.IsNaN(lowered.Mean) && !baselineDark; + var lowerSaturated = lowerMeasurable && IsSaturated(lowered.SaturatedFraction, criteria.MaxSaturatedFraction); + result.LowerChecked = lowerMeasurable && !lowerSaturated; + result.LowerOk = result.LowerChecked && (criteria.UseRatio + ? lowered.Mean / b <= criteria.LowerLimit + : b - lowered.Mean >= criteria.LowerLimit); + + var raiseMeasurable = baselineMeasured && raisedApplied && !double.IsNaN(raised.Mean) && !baselineSaturated + && (!criteria.UseRatio || !baselineDark); + var raiseSaturated = raiseMeasurable && IsSaturated(raised.SaturatedFraction, criteria.MaxSaturatedFraction); + result.RaiseChecked = raiseMeasurable && !raiseSaturated; + result.RaiseOk = result.RaiseChecked && (criteria.UseRatio + ? raised.Mean / b >= criteria.RaiseLimit + : raised.Mean - b >= criteria.RaiseLimit); + + var anyChecked = result.LowerChecked || result.RaiseChecked; + result.Pass = criteria.RequireAllChecked + ? anyChecked && (!result.LowerChecked || result.LowerOk) && (!result.RaiseChecked || result.RaiseOk) + : result.LowerOk || result.RaiseOk; + + var parts = new List { + lowerSaturated + ? DescribeSaturatedSide("下げる", b, lowered) + : DescribeSide("下げる", result.LowerChecked, result.LowerOk, loweredApplied, b, lowered.Mean, criteria, + baselineDark ? "基準が真っ暗" : null), + raiseSaturated + ? DescribeSaturatedSide("上げる", b, raised) + : DescribeSide("上げる", result.RaiseChecked, result.RaiseOk, raisedApplied, b, raised.Mean, criteria, + baselineSaturated ? "基準が飽和" : baselineDark ? "基準が真っ暗" : null), + }; + if (!anyChecked) parts.Add("どちらの向きも確認できません"); + else if (!criteria.RequireAllChecked && result.Pass && !(result.LowerOk && result.RaiseOk)) parts.Add("一方の向きが期待どおりのため合格"); + result.Summary = string.Join("/", parts); + return result; + } + + private static string DescribeSide(string label, bool isChecked, bool ok, bool applied, double baseline, double value, + HwTestDirectionCriteria criteria, string skipReason) { + var limit = label == "下げる" ? criteria.LowerLimit : criteria.RaiseLimit; + if (isChecked) { + var verdict = ok ? "期待どおり" : "期待と異なる"; + return criteria.UseRatio + ? $"{label}: 比 {value / baseline:0.000}({baseline:0.00}→{value:0.00}.{(label == "下げる" ? "≦" : "≧")} {limit:G3} を期待.{verdict})" + : $"{label}: 差 {value - baseline:+0.0;-0.0;0.0}({baseline:0.00}→{value:0.00}.{(label == "下げる" ? "−" : "+")}{limit:G3} 以上を期待.{verdict})"; + } + if (!applied) return $"{label}: 設定が変わらなかったため確認しない"; + if (double.IsNaN(value) || double.IsNaN(baseline)) return $"{label}: 測定できなかった"; + return $"{label}: {skipReason ?? "確認できない"}のため確認しない({baseline:0.00}→{value:0.00})"; + } + + /// 動かした側が飽和していて確認しない向きの説明(飽和画素の割合は判定に使う最大値) + private static string DescribeSaturatedSide(string label, double baseline, HwTestMeasurement side) { + return $"{label}: 飽和のため確認しない(飽和割合 {side.SaturatedFraction * 100:0.00}%.{baseline:0.00}→{side.Mean:0.00})"; + } + + /// + /// 読み戻した値の元の値に対する相対誤差(|読み戻し − 元| / |元|). + /// NaN を含む場合は NaN,元が 0 の場合は読み戻しも 0 なら 0・そうでなければ正の無限大. + /// + public static double RelativeError(double expected, double actual) { + if (double.IsNaN(expected) || double.IsNaN(actual)) return double.NaN; + var diff = Math.Abs(actual - expected); + if (expected == 0) return diff == 0 ? 0 : double.PositiveInfinity; + return diff / Math.Abs(expected); + } + + /// + /// 元に戻した設定値の読み戻しの説明(丸めない値(往復できる "R" 書式)と相対誤差(%)). + /// 例: 「露光 読み戻し 0.0166666666666667(元 0.0166666666666667.相対誤差 0%)」 + /// + public static string DescribeRestoredValue(string name, double expected, double actual) { + var inv = System.Globalization.CultureInfo.InvariantCulture; + var err = RelativeError(expected, actual); + var errText = double.IsNaN(err) ? "測定できない" + : double.IsInfinity(err) ? "—(元が 0)" + : (err * 100).ToString("0.######", inv) + "%"; + return $"{name} 読み戻し {actual.ToString("R", inv)}(元 {expected.ToString("R", inv)}.相対誤差 {errText})"; + } + + /// 点灯時と消灯時の平均輝度の差が下限以上か + public static bool JudgeLight(double onMean, double offMean, double minDelta) { + if (double.IsNaN(onMean) || double.IsNaN(offMean)) return false; + return onMean - offMean >= minDelta; + } + + /// 所要時間が上限以内か + public static bool WithinLimit(TimeSpan elapsed, TimeSpan limit) { + return elapsed <= limit; + } + + /// 元に戻した後の平均輝度が最初の平均輝度から許容内か + public static bool IsRestored(double baseline, double after, double tolerance) { + if (double.IsNaN(baseline) || double.IsNaN(after)) return false; + return Math.Abs(after - baseline) <= tolerance; + } + + /// + /// ウォッチドッグの判定.全体の上限を先に見る(全体を超えたら項目の状況によらず打ち切る). + /// 実行中の項目が無い(itemLimit が null)ときは項目の上限を見ない. + /// + public static HwTestOverdue FindOverdue(TimeSpan overallElapsed, TimeSpan overallLimit, + TimeSpan itemElapsed, TimeSpan? itemLimit) { + if (overallElapsed > overallLimit) return HwTestOverdue.Overall; + if (itemLimit.HasValue && itemElapsed > itemLimit.Value) return HwTestOverdue.Item; + return HwTestOverdue.None; + } + + /// + /// 実機テストの間,カメラのフレームコールバックでの校正処理を止めるか. + /// チャート不要の検査(--with-chart なし)ではカメラの前にチャート(または白板)をはめたまま + /// 露光・ゲイン・光源を検査するため,校正(ホワイトバランスの調整等)が走ると測定が乱れる.そこで止める. + /// チャートが必要な検査(--with-chart.校正そのものを検査する)では止めない. + /// + /// --with-chart が指定されたか + public static bool ShouldSuppressCalibration(bool withChart) { + return !withChart; + } + + /// + /// 校正を止めているはずなのに校正が始まった(または済んだ)か.真なら校正の停止が効いていない(不合格). + /// 校正を止めていない場合は常に偽(校正が走るのは想定どおり). + /// + /// 校正を止めているか(ShouldSuppressCalibration の結果) + /// 校正中か(CameraBase.IsCalibrating) + /// 校正済みか(CameraBase.HwTestIsCalibrated) + public static bool IsCalibrationLeaked(bool suppressed, bool isCalibrating, bool isCalibrated) { + return suppressed && (isCalibrating || isCalibrated); + } + + /// + /// 実機テストの終了コード.指定機種が見つからない(実機テスト自体を行えない)ときは 2, + /// 不合格の項目があれば 1,すべて合格なら 0(タイムアウトの 3 はウォッチドッグが返す). + /// + public static int DecideExitCode(bool cameraNotFound, int failedCount) { + if (cameraNotFound) return HwTestExitCodes.Error; + return failedCount > 0 ? HwTestExitCodes.Fail : HwTestExitCodes.Pass; + } + + /// + /// 作業フォルダ(%TEMP%\TIASshot-hwtest\work-*)を残すか.--hwtest-keep の指定があれば常に残す. + /// 不合格(1)・タイムアウト(3)は証拠として残し,合格(0)と前提を満たさずに終わった場合(2: 引数不正・指定機種が見つからない等)は消す. + /// + /// --hwtest-keep が指定されたか + /// 実機テストの終了コード(HwTestExitCodes) + public static bool ShouldKeepWorkDir(bool keepRequested, int exitCode) { + if (keepRequested) return true; + return exitCode == HwTestExitCodes.Fail || exitCode == HwTestExitCodes.Timeout; + } + + private static void ValidateRange(double current, double min, double max) { + if (!IsFinite(current) || !IsFinite(min) || !IsFinite(max)) { + throw new ArgumentException($"設定値・範囲に非有限値が含まれます: {current}({min}〜{max})"); + } + if (min > max) throw new ArgumentException($"範囲が不正です: {min}〜{max}"); + } + + private static bool IsFinite(double value) { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + + /// 値を min〜max に収める(NaN はそのまま返す).機種ごとのアダプタが設定値を範囲内に収めるのにも使う + internal static double Clamp(double value, double min, double max) { + if (value < min) return min; + if (value > max) return max; + return value; + } + } + + /// 実機テストの終了コード(HWTEST 限定) + internal static class HwTestExitCodes { + public const int Pass = 0; + public const int Fail = 1; + public const int Error = 2; + public const int Timeout = 3; + } +} +#endif diff --git a/TIASshot/HwTest/HwTestOptions.cs b/TIASshot/HwTest/HwTestOptions.cs new file mode 100644 index 0000000..10c3107 --- /dev/null +++ b/TIASshot/HwTest/HwTestOptions.cs @@ -0,0 +1,131 @@ +#if HWTEST +using System; + +namespace TIASshot { + + /// 実機テストで使うカメラの機種(HWTEST 限定) + internal enum HwTestCameraKind { + /// Lumenera(Lucam) + Lucam, + /// ImagingSource(IScam) + IScam, + } + + /// + /// 実機テスト(--hwtest)の引数(HWTEST 限定.SDK・UI に依存しない純粋な解析). + /// TIASshot.exe --hwtest --camera <lucam|iscam> [--hwtest-out <path>] [--with-chart] [--hwtest-keep] + /// + internal sealed class HwTestOptions { + + public const string FlagHwTest = "--hwtest"; + public const string FlagCamera = "--camera"; + public const string FlagOut = "--hwtest-out"; + public const string FlagWithChart = "--with-chart"; + public const string FlagKeep = "--hwtest-keep"; + + /// 検査するカメラの機種 + public HwTestCameraKind Camera { get; private set; } + + /// 結果ファイルの出力先(省略時は null.呼び出し側が既定のパスを決める) + public string OutPath { get; private set; } + + /// チャートが必要な検査も行うか(フェーズ 2.現状は未実装) + public bool WithChart { get; private set; } + + /// 合格時も作業フォルダ(一時設定・保存物)を削除しないか + public bool Keep { get; private set; } + + /// 実機テストが要求されているか(--hwtest があるか) + public static bool IsRequested(string[] args) { + if (args == null) return false; + foreach (var a in args) { + if (Is(a, FlagHwTest)) return true; + } + return false; + } + + /// + /// 引数を解析する.先頭要素が実行ファイルのパス(Environment.GetCommandLineArgs の形)でもよい. + /// 知らないフラグ("--" 始まり)・値の欠落・--camera の指定漏れや不正値は誤りとする(打ち間違いで別の検査が走らないため). + /// + /// コマンドライン引数 + /// 誤りがあればその理由(日本語),無ければ null + /// 解析結果.誤りがあれば null + public static HwTestOptions Parse(string[] args, out string error) { + error = null; + if (args == null) { + error = "引数がありません"; + return null; + } + var options = new HwTestOptions(); + string camera = null; + for (var i = 0; i < args.Length; i++) { + var a = args[i]; + if (a == null || !a.StartsWith("--", StringComparison.Ordinal)) { + // 実行ファイルのパス(先頭)以外の値は,直前のフラグの値として読み終えているはず + if (i == 0) continue; + error = $"不明な引数です: {a}"; + return null; + } + if (Is(a, FlagHwTest)) continue; + if (Is(a, FlagWithChart)) { options.WithChart = true; continue; } + if (Is(a, FlagKeep)) { options.Keep = true; continue; } + if (Is(a, FlagCamera) || Is(a, FlagOut)) { + var value = i + 1 < args.Length ? args[i + 1] : null; + if (string.IsNullOrWhiteSpace(value) || value.StartsWith("--", StringComparison.Ordinal)) { + error = $"{a} の値がありません"; + return null; + } + i++; + if (Is(a, FlagCamera)) { + if (camera != null) { + error = $"{FlagCamera} が複数回指定されています"; + return null; + } + camera = value; + } else { + if (options.OutPath != null) { + error = $"{FlagOut} が複数回指定されています"; + return null; + } + options.OutPath = value; + } + continue; + } + error = $"不明なフラグです: {a}"; + return null; + } + + if (camera == null) { + error = $"{FlagCamera} lucam または {FlagCamera} iscam を指定してください"; + return null; + } + HwTestCameraKind kind; + if (!TryParseCamera(camera, out kind)) { + error = $"{FlagCamera} の値が不正です: {camera}(lucam または iscam)"; + return null; + } + options.Camera = kind; + return options; + } + + /// 機種名(lucam / iscam.大文字小文字は区別しない)を解析する + public static bool TryParseCamera(string value, out HwTestCameraKind kind) { + if (string.Equals(value, "lucam", StringComparison.OrdinalIgnoreCase)) { + kind = HwTestCameraKind.Lucam; + return true; + } + if (string.Equals(value, "iscam", StringComparison.OrdinalIgnoreCase)) { + kind = HwTestCameraKind.IScam; + return true; + } + kind = HwTestCameraKind.Lucam; + return false; + } + + private static bool Is(string arg, string flag) { + return string.Equals(arg, flag, StringComparison.OrdinalIgnoreCase); + } + } +} +#endif diff --git a/TIASshot/HwTest/HwTestRunner.cs b/TIASshot/HwTest/HwTestRunner.cs new file mode 100644 index 0000000..55a33c8 --- /dev/null +++ b/TIASshot/HwTest/HwTestRunner.cs @@ -0,0 +1,1097 @@ +#if HWTEST +#if !SELFTEST +#error HWTEST は SELFTEST の部品(SelfTestConfig・SelfTestReport・SelfTestPump)を使うため,SELFTEST と同時に定義すること +#endif +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Threading; +using System.Windows.Forms; + +namespace TIASshot { + + /// + /// 実機テスト(HWTEST 限定).TIASshot.exe --hwtest --camera <lucam|iscam> [--hwtest-out <path>] [--with-chart] で起動すると, + /// 実際の Form1 を表示し,指定した機種のカメラと光源を操作して次の項目を検査し,終了コードと結果ファイルで結果を返す(PLAN_06). + /// + /// connect: 指定した機種に接続でき,機種名・シリアル番号が取れる + /// preview: プレビューが流れる(フレームレートの下限) + /// exposure-gain: 露光・ゲインの上げ下げがフレームの平均輝度に期待どおりに効き(露光は比,ゲインは差),元の値(絶対値)へ正確に戻せる + /// light: 光源の点灯・消灯が平均輝度に効き,ポートを閉じた後に開き直せる + /// close-preview: プレビュー中に閉じる処理を呼んでもすぐ戻る + /// reconnect: 切断した後に再接続でき,フレームが届く + /// cleanup: 後始末(露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯はしない))と,設定・デバイス状態ファイルを書き換えていないこと + /// + /// 保存先は一時フォルダ(%TEMP%\TIASshot-hwtest\work-*)に差し替え,同梱 config.xml・デバイス状態ファイル({DeviceName}.xml)・本番の保存先には書き込まない. + /// 終了コード: 0=全項目合格,1=不合格あり,2=実機テスト自体の異常(引数不正・指定機種が見つからない・未実装の指定等),3=タイムアウト. + /// 【注意】判定のしきい値(HwTestThresholds)は暫定値.結果ファイルには必ず測定値を出す. + /// + internal static 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); + } + + /// + /// 実機テストを実行して終了コードを返す(UI スレッド=STA のメインスレッドから呼ぶこと). + /// + public static int Run(string[] args) { + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + var stamp = $"{DateTime.Now:yyyyMMdd-HHmmss}-{Process.GetCurrentProcess().Id}"; + var tempRoot = Path.Combine(Path.GetTempPath(), "TIASshot-hwtest"); + var workDir = Path.Combine(tempRoot, $"work-{stamp}"); + + var options = HwTestOptions.Parse(args, out var parseError); + var outPath = options?.OutPath ?? RawArgValue(args, HwTestOptions.FlagOut) ?? Path.Combine(tempRoot, $"result-{stamp}.txt"); + + SelfTestReport report; + try { + outPath = Path.GetFullPath(outPath); + report = new SelfTestReport(outPath); + } catch (Exception ex) { + Debug.WriteLine($"実機テストの結果ファイルを作成できません: {outPath}: {ex}"); + return HwTestExitCodes.Error; + } + + using (report) { + report.WriteLine("TIASshot hwtest"); + report.WriteLine($" exe: {Assembly.GetExecutingAssembly().Location}"); + report.WriteLine($" version: {Assembly.GetExecutingAssembly().GetName().Version}"); + report.WriteLine($" build: {BuildDescription()}"); + report.WriteLine($" process: {(Environment.Is64BitProcess ? "x64" : "x86")} / CLR {Environment.Version}"); + report.WriteLine($" started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + report.WriteLine($" args: {string.Join(" ", args.Skip(1))}"); + report.WriteLine($" result file: {outPath}"); + + if (options == null) { + report.WriteLine(); + report.WriteLine($"[FAIL] args: 引数が不正です: {parseError}"); + report.WriteLine(" - 使い方: TIASshot.exe --hwtest --camera [--hwtest-out ] [--with-chart] [--hwtest-keep]"); + report.WriteLine("RESULT: FAIL (invalid arguments)"); + 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($" work dir: {workDir}"); + report.WriteLine($" thresholds (暫定): {DescribeThresholds()}"); + var suppressCalibration = HwTestJudge.ShouldSuppressCalibration(options.WithChart); + report.WriteLine(suppressCalibration + ? " 校正: 停止(チャート不要の検査.チャートが写っても校正(チャート検出・ホワイトバランスの調整・変換行列の算出)は走らない)" + : " 校正: 有効"); + report.WriteLine(); + + var session = new Session { + Report = report, + Options = options, + BaseDir = baseDir, + WorkDir = workDir, + CalibrationSuppressed = suppressCalibration, + }; + // カメラの接続(コールバック開始)より前に設定する.static のため再接続で作り直したカメラにも効く + CameraBase.HwTestCalibrationSuppressed = suppressCalibration; + int exitCode; + using (var done = new ManualResetEventSlim(false)) { + StartWatchdog(session, done); + AppDomain.CurrentDomain.UnhandledException += (s, e) => { + report.WriteLine($"[FAIL] unhandled: 未処理例外でプロセスが終了します: {SelfTestReport.DescribeException(e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString()))}"); + EmergencyCleanup(session); + report.WriteLine("RESULT: FAIL (unhandled exception)"); + Environment.Exit(HwTestExitCodes.Error); + }; + + try { + exitCode = RunChecks(session); + } catch (Exception ex) { + report.WriteLine($"[FAIL] runner: 実機テスト自体が異常終了しました: {SelfTestReport.DescribeException(ex)}"); + // 項目の外で失敗した場合も後始末(光源・カメラ)は行う + RunCleanupSafely(session); + report.WriteLine("RESULT: FAIL (runner error)"); + exitCode = HwTestExitCodes.Error; + } + lock (session.StateLock) session.Finished = true; + done.Set(); + CameraBase.HwTestCalibrationSuppressed = false; // 検査が終わったら元に戻す(カメラは切断済み) + } + + CleanupWorkDir(report, workDir, HwTestJudge.ShouldKeepWorkDir(options.Keep, exitCode)); + return exitCode; + } + } + + // ================================================================================ + // 共通 + // ================================================================================ + + private sealed class Session { + public SelfTestReport Report; + public HwTestOptions Options; + public bool CalibrationSuppressed; // 校正を止めているか(チャート不要の検査) + public string BaseDir; + public string WorkDir; + public string SaveRoot; + public string UserSaveFolder; + + public Form1 Form; + public CameraBase Camera; // 現在のカメラ + public bool CameraConnected; // 切断が必要か + public string FirstSerial; // 1 回目の接続のシリアル番号(再接続の照合用) + public bool CameraNotFound; // 指定機種が見つからない(終了コード 2) + + // 露光・ゲインを変えたまま終わらないための記録(RestorePending の間は後始末で元に戻す) + public IHwTestAdjustable RestoreTarget; + public double OriginalExposure; + public double OriginalGain; + public double OriginalGainRangeWidth; // ゲインの読み戻しの絶対誤差の許容に使う(設定範囲の幅) + public bool RestorePending; + // 露光・ゲインを変える前の最初の平均輝度(元に戻した後の比較用.参考値) + public double BaselineAfterRestoreCheck = double.NaN; + + public bool CleanupDone; + + // ウォッチドッグが見る実行中の項目(StateLock で保護) + public readonly object StateLock = new object(); + public SelfTestCheck CurrentCheck; + public string CurrentStep; + public TimeSpan? CurrentLimit; + public Stopwatch ItemWatch = new Stopwatch(); + public bool Finished; + } + + private static string BuildDescription() { + var flags = new List(); +#if DEBUG + flags.Add("DEBUG"); +#endif +#if MEMMONITOR + flags.Add("MEMMONITOR"); +#endif +#if WHITEBOARD + flags.Add("WHITEBOARD"); +#endif + flags.Add("SELFTEST"); + flags.Add("HWTEST"); + return string.Join(" ", flags); + } + + private static string DescribeThresholds() { + return $"preview>={HwTestThresholds.MinPreviewFps}fps, " + + $"exposure ratio down<={HwTestThresholds.ExposureLowerMaxRatio} up>={HwTestThresholds.ExposureRaiseMinRatio}, " + + $"gain delta>={HwTestThresholds.MinBrightnessDelta} (one side ok), " + + $"baseline mid={HwTestThresholds.MidBrightnessLow}-{HwTestThresholds.MidBrightnessHigh} (max {HwTestThresholds.BaselineSearchMaxSteps} steps), " + + $"restore rel<={HwTestThresholds.RestoreRelativeTolerance:P0}, " + + $"light delta>={HwTestThresholds.MinLightDelta}, close<={HwTestThresholds.CloseLimit.TotalMilliseconds:0}ms, " + + $"frames after close<={HwTestThresholds.MaxFramesAfterClose}, reconnect frames>={HwTestThresholds.ReconnectMinFrames}, " + + $"dark<={HwTestThresholds.DarkLevel}, saturated pixel>={HwTestThresholds.SaturatedPixelLevel} fraction>{HwTestThresholds.MaxSaturatedFraction:P0}"; + } + + /// 引数の解析に失敗した場合でも結果ファイルの出力先だけは拾う(直後の値.無ければ null) + private static string RawArgValue(string[] args, string name) { + for (var i = 0; i < args.Length - 1; i++) { + if (!string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) continue; + var value = args[i + 1]; + return string.IsNullOrWhiteSpace(value) || value.StartsWith("--", StringComparison.Ordinal) ? null : value; + } + return null; + } + + /// + /// ウォッチドッグ.実行中の項目が項目の上限を,または全体が全体の上限を超えたら, + /// その項目の途中までの記録(測定値)と「どのステップで止まったか」を結果ファイルに書き, + /// 光源のポートを閉じて(消灯はしない)から終了コード 3 で終了する(UI スレッドが SDK 呼び出しで止まっていても動く). + /// + private static void StartWatchdog(Session session, ManualResetEventSlim done) { + var overall = Stopwatch.StartNew(); + var watchdog = new Thread(() => { + while (!done.Wait(200)) { + string id, step; + TimeSpan itemElapsed; + TimeSpan? limit; + SelfTestCheck check; + lock (session.StateLock) { + if (session.Finished) return; + check = session.CurrentCheck; + id = check?.Id; + step = session.CurrentStep; + itemElapsed = session.ItemWatch.Elapsed; + limit = session.CurrentLimit; + } + var overdue = HwTestJudge.FindOverdue(overall.Elapsed, HwTestThresholds.OverallTimeout, 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} 秒以内に終わりません"; + var message = $"{reason}(実行中のステップ: {step ?? "不明"}.SDK 呼び出しのハング・Invoke の相互待機・モーダル表示の疑い)"; + if (check != null) { + check.Fail($"タイムアウト: {message}"); + try { + report.WriteCheck(check); + } catch (InvalidOperationException ex) { + // UI スレッドが同時に記録を足していた場合(列挙中の変更).見出しだけは残す + report.WriteLine($"[FAIL] {id}: タイムアウト(途中の記録を書き出せませんでした: {ex.Message})"); + report.WriteLine($" FAIL: {message}"); + } + } else { + report.WriteLine($"[FAIL] watchdog: {message}"); + } + EmergencyCleanup(session); + report.WriteLine($"RESULT: FAIL (timeout{(id != null ? $": {id}" : "")})"); + Environment.Exit(HwTestExitCodes.Timeout); + } + }) { + IsBackground = true, + Name = "HwTest.Watchdog", + }; + watchdog.Start(); + } + + /// + /// ウォッチドッグ・未処理例外からの緊急の後始末(別スレッドから呼ばれる).光源のポートを閉じる(消灯はしない.上限 5 秒). + /// カメラ(露光・ゲインの復元・切断)は UI スレッドが SDK 呼び出しで止まっている可能性があるため触らない + /// (露光・ゲインはカメラ側にだけ残り,次の接続でデバイス状態ファイル(IScam)・config.xml(Lucam)の値に戻る). + /// + private static void EmergencyCleanup(Session session) { + var form = session.Form; + var report = session.Report; + if (form == null) return; + var t = new Thread(() => { + try { + form.HwTestEmergencyCloseLight(); + } catch (Exception ex) { + report.WriteLine($" WARN: 光源のポートを緊急に閉じられませんでした: {SelfTestReport.DescribeException(ex)}"); + } + }) { IsBackground = true, Name = "HwTest.EmergencyLight" }; + t.Start(); + if (t.Join(TimeSpan.FromSeconds(5))) { + report.WriteLine(" - 緊急の後始末: 光源のポートを閉じました(消灯はしていません.カメラの露光・ゲインの復元と切断は行っていません)"); + } else { + report.WriteLine(" WARN: 緊急の後始末: 光源のポートのクローズが 5 秒以内に終わりません(ポートが開いたままの可能性)"); + } + if (session.RestorePending) { + report.WriteLine($" WARN: 露光・ゲインを元に戻せていません(元の値: 露光 {session.OriginalExposure:G6},ゲイン {session.OriginalGain:G6}.次の接続で設定ファイルの値に戻ります)"); + } + } + + /// 1 項目を実行し,想定外の例外は FAIL として記録する.実行中はウォッチドッグが項目の上限を見る + private static SelfTestCheck RunCheck(Session session, string id, string title, TimeSpan limit, Action body) { + var check = new SelfTestCheck(id, title); + var form = session.Form; + var errorsBefore = form?.HwTestErrors.Count ?? 0; + lock (session.StateLock) { + session.CurrentCheck = check; + session.CurrentStep = "開始"; + session.CurrentLimit = limit; + session.ItemWatch.Restart(); + } + try { + body(check); + } catch (Exception ex) { + check.FailException("想定外の例外", ex); + } + if (form != null) { + var frameError = form.HwTestFrameError; + if (frameError != null) check.Fail($"フレームの記録中の例外(ShowImage): {frameError}"); + var errors = form.HwTestErrors; + for (var i = errorsBefore; i < errors.Count; i++) { + check.Fail($"エラー通知(ShowError)が発生: {errors[i]}"); + } + } + long elapsed; + lock (session.StateLock) { + elapsed = session.ItemWatch.ElapsedMilliseconds; + session.CurrentCheck = null; + session.CurrentStep = null; + session.CurrentLimit = null; + } + check.Info($"所要時間 {elapsed} ms"); + session.Report.WriteCheck(check); + return check; + } + + /// 実行中のステップを記録する(ハング時に結果ファイルへ出す) + private static void SetStep(Session session, string step) { + lock (session.StateLock) session.CurrentStep = step; + Debug.WriteLine($"[HWTEST] {step}"); + } + + /// 前提が満たされず実行しない項目を FAIL として記録する + private static SelfTestCheck SkipCheck(Session session, string id, string reason) { + var check = new SelfTestCheck(id, "(実行しません)"); + check.Fail(reason); + session.Report.WriteCheck(check); + return check; + } + + // ================================================================================ + // 実行の流れ + // ================================================================================ + + private static int RunChecks(Session session) { + var report = session.Report; + var checks = new List(); + + // WinForms の UI 例外をダイアログ表示ではなく例外として呼び出し元(DoEvents)へ伝播させる + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException); + // デバイス状態ファイル({DeviceName}.xml)は相対パスで読まれるため,通常起動と同じく実行ファイルのフォルダを基準にする + Environment.CurrentDirectory = session.BaseDir; + + var snapshot = FileSnapshot.Take(session.BaseDir); + PrepareConfig(session); + report.WriteLine($" - 保存先を一時フォルダに差し替え: {session.SaveRoot}(config.xml の {session.UserSaveFolder} には書き込まない)"); + var userSaveBefore = SaveFolderSnapshot.Take(session.UserSaveFolder); + + session.Form = new Form1(Form1.HwTestMarker.Instance); + session.Form.Show(); // Form1_Load(実機テストのモードでは自動接続しない)が走る + SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(300)); + + var connect = RunCheck(session, "connect", $"指定した機種({session.Options.Camera})に接続でき,機種名・シリアル番号が取れる", + HwTestThresholds.ConnectTimeout, c => CheckConnect(session, c)); + checks.Add(connect); + + if (connect.Status == SelfTestStatus.Pass) { + checks.Add(RunCheck(session, "preview", "プレビューが流れる(フレームレートの下限)", + HwTestThresholds.PreviewTimeout, c => CheckPreview(session, c))); + checks.Add(RunCheck(session, "exposure-gain", "露光・ゲインの上げ下げがフレームの平均輝度に期待どおりの向きで効く", + HwTestThresholds.ExposureGainTimeout, c => CheckExposureGain(session, c))); + checks.Add(RunCheck(session, "light", "光源の点灯・消灯が平均輝度に効き,ポートを閉じた後に開き直せる", + HwTestThresholds.LightTimeout, c => CheckLight(session, c))); + checks.Add(RunCheck(session, "close-preview", "プレビュー中に閉じる処理(Form1_FormClosing)を呼んでもすぐ戻る", + HwTestThresholds.ClosePreviewTimeout, c => CheckClosePreview(session, c))); + checks.Add(RunCheck(session, "reconnect", "切断した後に再接続でき,フレームが届く", + HwTestThresholds.ReconnectTimeout, c => CheckReconnect(session, c))); + } else { + var reason = session.CameraNotFound + ? $"指定した機種({session.Options.Camera})が見つからないため実行しません" + : "前提の connect が失敗したため実行しません"; + foreach (var id in CheckIds.Skip(1).Take(5)) checks.Add(SkipCheck(session, id, reason)); + } + + checks.Add(RunCheck(session, "cleanup", "後始末(露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる)と,設定・デバイス状態ファイル・本番の保存先を書き換えていない", + HwTestThresholds.CleanupTimeout, c => CheckCleanup(session, c, snapshot, userSaveBefore))); + + var failed = checks.Where(x => x.Status == SelfTestStatus.Fail).ToList(); + var exitCode = HwTestJudge.DecideExitCode(session.CameraNotFound, failed.Count); + report.WriteLine(); + if (session.CameraNotFound) { + report.WriteLine($"RESULT: FAIL (camera not found: {session.Options.Camera})"); + } else if (failed.Count == 0) { + report.WriteLine($"RESULT: PASS ({checks.Count}/{checks.Count})"); + } else { + report.WriteLine($"RESULT: FAIL ({failed.Count}/{checks.Count} failed: {string.Join(", ", failed.Select(x => x.Id))})"); + } + return exitCode; + } + + /// + /// 実機テスト用の一時設定を作り,Config に読み込む(セルフテストと共通の SelfTestConfig を使う). + /// 同梱 config.xml を複製し,保存先を作業フォルダに,参照 CSV を実行ファイル基準の絶対パスに,効果音を存在しないパスに差し替える. + /// + /// 一時設定を準備・読み込みできない場合(実機テスト自体の異常) + private static void PrepareConfig(Session session) { + var prepared = SelfTestConfig.Prepare(Path.Combine(session.BaseDir, "config.xml"), session.BaseDir, session.WorkDir, + fileName: ConfigFileName); + if (!Config.IsLoaded()) throw new InvalidOperationException($"一時設定ファイルを読み込めません: {prepared.ConfigPath}"); + session.SaveRoot = prepared.SaveRoot; + session.UserSaveFolder = prepared.UserSaveFolder; + } + + // ================================================================================ + // 平均輝度の測定 + // ================================================================================ + + /// 設定を変えた後,フレームに反映されるまで待つ(メッセージを処理しながら) + private static void Settle(Session session, string what) { + SetStep(session, $"{what}: 反映待ち"); + SelfTestPump.PumpFor(HwTestThresholds.SettleTime); + } + + /// + /// 次に届くフレームの平均輝度と飽和画素の割合を測る(HwTestThresholds.SamplesPerMeasurement 枚.平均輝度は平均,飽和画素の割合は最大で判定). + /// 測定値は必ず結果に出す.上限内に揃わなければ FAIL を記録し,届いた分の測定値(1 枚も無ければ NotMeasured)を返す. + /// + private static HwTestMeasurement MeasureBrightness(Session session, SelfTestCheck c, string label) { + var form = session.Form; + var n = HwTestThresholds.SamplesPerMeasurement; + SetStep(session, $"{label}: 平均輝度の測定"); + form.HwTestRequestSamples(n); + var ok = SelfTestPump.PumpUntil(() => form.HwTestSampleCount >= n, HwTestThresholds.SampleTimeout); + var samples = form.HwTestTakeSamples(); + var m = HwTestJudge.Summarize(samples); + if (!ok) { + c.Fail($"{label}: {HwTestThresholds.SampleTimeout.TotalSeconds:0} 秒以内に {n} フレーム届きません({samples.Count} フレーム.平均輝度 {m.Mean:0.0})"); + return m; + } + var saturated = HwTestJudge.IsSaturated(m.SaturatedFraction, HwTestThresholds.MaxSaturatedFraction); + c.Info($"{label}: 平均輝度 {m.Mean:0.00}({samples.Count} フレーム,最小 {samples.Min(x => x.Mean):0.00} / 最大 {samples.Max(x => x.Mean):0.00})," + + $"飽和画素({HwTestThresholds.SaturatedPixelLevel} 以上)の割合 最大 {m.SaturatedFraction:P2} / 平均 {m.MeanSaturatedFraction:P2}" + + (saturated ? $"(飽和: しきい値 {HwTestThresholds.MaxSaturatedFraction:P0} 超え)" : "")); + return m; + } + + // ================================================================================ + // connect + // ================================================================================ + + private static void CheckConnect(Session session, SelfTestCheck c) { + var form = session.Form; + var kind = session.Options.Camera; + + SetStep(session, "機種の列挙"); + if (kind == HwTestCameraKind.Lucam) { + int count; + try { + count = Form1.HwTestCountLucam(); + } catch (Exception ex) { + c.FailException("Lumenera カメラの列挙(LucamNumCameras)に失敗しました(SDK 未導入の可能性)", ex); + session.CameraNotFound = true; + return; + } + c.Info($"Lumenera カメラ: {count} 台"); + if (count < 1) { + c.Fail("Lumenera カメラが見つかりません(USB の接続を確認してください)"); + session.CameraNotFound = true; + return; + } + } else { + string[] names; + try { + names = form.HwTestListIScam(); + } catch (Exception ex) { + c.FailException("ImagingSource カメラの列挙(ICImagingControl.Devices)に失敗しました(SDK・ドライバ未導入の可能性)", ex); + session.CameraNotFound = true; + return; + } + c.Info($"ImagingSource カメラ: {names.Length} 台({string.Join(", ", names)})"); + if (names.Length < 1) { + c.Fail("ImagingSource カメラが見つかりません(USB の接続を確認してください)"); + session.CameraNotFound = true; + return; + } + } + + SetStep(session, "Connect"); + var sw = Stopwatch.StartNew(); + var camera = form.HwTestConnect(kind); + var connectErrors = form.HwTestTakeConnectErrors(); + foreach (var e in connectErrors) c.Info($"接続失敗の記録: {e}"); + if (!c.Expect(camera != null, $"Connect() に失敗しました({string.Join(" / ", connectErrors)})")) return; + session.Camera = camera; + session.CameraConnected = true; + c.Info($"Connect(): {sw.ElapsedMilliseconds} ms"); + + var kindOk = kind == HwTestCameraKind.Lucam ? camera is Lucam : camera is IScam; + c.Expect(kindOk, $"接続したカメラの種類が指定と異なります: {camera.GetType().Name}(指定 {kind})"); + c.Info($"機種名: {camera.DeviceName},シリアル番号: {camera.SerialNumber}"); + c.Expect(!string.IsNullOrWhiteSpace(camera.DeviceName) && camera.DeviceName != "Unknown", $"機種名が取れません: {camera.DeviceName}"); + c.Expect(!string.IsNullOrWhiteSpace(camera.SerialNumber), "シリアル番号が取れません"); + session.FirstSerial = camera.SerialNumber; + + SetStep(session, "StartSession(画面・光源・副モニタ・再描画タイマー)"); + form.HwTestStartSession(); + c.Info($"光源のポート: {(form.HwTestLightIsOpen ? "開いた(点灯)" : "開けなかった(光源が見つからない)")}"); + } + + // ================================================================================ + // preview + // ================================================================================ + + private static void CheckPreview(Session session, SelfTestCheck c) { + var form = session.Form; + + SetStep(session, "最初のフレームの待機"); + var sw = Stopwatch.StartNew(); + var first = SelfTestPump.PumpUntil(() => form.HwTestFrameCount > 0, HwTestThresholds.FirstFrameTimeout); + if (!c.Expect(first, $"{HwTestThresholds.FirstFrameTimeout.TotalSeconds:0} 秒以内にフレームが届きません(接続後 {form.HwTestFrameCount} フレーム)")) return; + c.Info($"最初のフレームまで: {sw.ElapsedMilliseconds} ms(接続処理の後から)"); + + SetStep(session, "慣らし"); + SelfTestPump.PumpFor(HwTestThresholds.PreviewWarmup); + + SetStep(session, "フレームレートの測定"); + var n0 = form.HwTestFrameCount; + var measure = Stopwatch.StartNew(); + SelfTestPump.PumpFor(HwTestThresholds.PreviewMeasure); + var frames = form.HwTestFrameCount - n0; + var elapsed = measure.Elapsed; + var fps = HwTestJudge.FrameRate(frames, elapsed); + var size = form.HwTestLastFrameSize; + c.Info($"フレームレート: {fps:0.00} fps({frames} フレーム / {elapsed.TotalSeconds:0.00} 秒),フレームの大きさ {size.Width}x{size.Height}"); + c.Expect(HwTestJudge.MeetsMinFrameRate(fps, HwTestThresholds.MinPreviewFps), + $"フレームレートが下限(暫定 {HwTestThresholds.MinPreviewFps} fps)を下回りました: {fps:0.00} fps"); + + c.Expect(form.HwTestDisplayedImage != null, "本モニタ(picDisplay)に画像が表示されていません(MainRefreshTimer_Tick の経路)", + "本モニタ(picDisplay)に表示用 Bitmap あり(タイマー駆動の再描画)"); + + MeasureBrightness(session, c, "プレビュー"); + ExpectCalibrationStopped(session, c); + } + + /// + /// 校正を止めているはずの状態で校正が始まって(または済んで)いないことを確かめる(止められていなければ FAIL). + /// 校正が走ると露光・ゲイン・光源の検査の測定が乱れ,カメラ本体のホワイトバランスも校正後の値のまま残る. + /// + private static void ExpectCalibrationStopped(Session session, SelfTestCheck c) { + var camera = session.Camera; + var calibrating = camera.IsCalibrating; + var calibrated = camera.HwTestIsCalibrated; + if (!session.CalibrationSuppressed) { + c.Info($"校正の状態: {(calibrating ? "校正中" : calibrated ? "校正済み" : "未校正")}(校正は止めていない)"); + return; + } + c.Expect(!HwTestJudge.IsCalibrationLeaked(session.CalibrationSuppressed, calibrating, calibrated), + $"校正を止めているはずなのに校正が{(calibrating ? "始まっています" : "済んでいます")}(CameraBase.HwTestCalibrationSuppressed が効いていません.露光・ゲイン・光源の測定が乱れます)", + "校正は止まっています(チャート不要の検査)"); + } + + // ================================================================================ + // exposure-gain + // ================================================================================ + + private static void CheckExposureGain(Session session, SelfTestCheck c) { + var adj = session.Camera as IHwTestAdjustable; + if (!c.Expect(adj != null, $"このカメラ({session.Camera.GetType().Name})は露光・ゲインの操作に対応していません")) return; + + SetStep(session, "露光・ゲインの読み取り"); + var exposure = adj.HwTestGetExposure(); + var gain = adj.HwTestGetGain(); + c.Info($"露光: {exposure}(単位: {adj.HwTestExposureUnit}.値の種類: {DescribeScale(adj.HwTestExposureScale)})"); + c.Info($"ゲイン: {gain}(単位: {adj.HwTestGainUnit})"); + session.RestoreTarget = adj; + session.OriginalExposure = exposure.Value; + session.OriginalGain = gain.Value; + session.OriginalGainRangeWidth = gain.Max - gain.Min; + session.RestorePending = true; + + try { + // 基準の明るさを中間に寄せる(飽和・真っ暗のままでは上げ下げの変化を判定できないため). + // 動かした露光は最後に RestoreSettings で元の値へ戻す.ゲインの上げ下げもこの基準の露光で行う + var baseExposure = FindBaselineExposure(session, c, adj, exposure); + + CheckOneSetting(session, c, "露光", + adj.HwTestGetExposure, adj.HwTestSetExposure, baseExposure, + NextExposure(adj, baseExposure, -1), + NextExposure(adj, baseExposure, 1), + HwTestJudge.ExposureCriteria()); + + CheckOneSetting(session, c, "ゲイン", + adj.HwTestGetGain, adj.HwTestSetGain, gain, + HwTestJudge.StepSetting(gain.Value, gain.Min, gain.Max, -HwTestThresholds.GainStepFraction), + HwTestJudge.StepSetting(gain.Value, gain.Min, gain.Max, HwTestThresholds.GainStepFraction), + HwTestJudge.GainCriteria()); + + // 元の値に戻ったことを確認する(測定値の戻りは参考.設定値の読み戻しは判定) + RestoreSettings(session, c); + Settle(session, "元の値"); + var restored = MeasureBrightness(session, c, "元に戻した後"); + if (!double.IsNaN(session.BaselineAfterRestoreCheck) && !HwTestJudge.IsRestored(session.BaselineAfterRestoreCheck, restored.Mean, HwTestThresholds.RestoreTolerance)) { + c.Warn($"元に戻した後の平均輝度が最初と離れています: {restored.Mean:0.00}(最初 {session.BaselineAfterRestoreCheck:0.00},許容 ±{HwTestThresholds.RestoreTolerance})"); + } + } finally { + if (session.RestorePending) { + // 途中で失敗しても変えた値を元に戻す + try { + RestoreSettings(session, c); + } catch (Exception ex) { + c.FailException("露光・ゲインを元に戻せませんでした(後始末で再試行します)", ex); + } + } + } + } + + private static string DescribeScale(HwTestValueScale scale) { + switch (scale) { + case HwTestValueScale.Log2Step: return "2^n のきりのよい段(基準探しの前に最も近い 2^n に合わせ,1 段 = 時間で 2 倍・半分.復元は元の値そのもの)"; + case HwTestValueScale.Linear: return $"線形の量(1 段 = ×{HwTestThresholds.ExposureDownFactor:G3} / ×{HwTestThresholds.ExposureUpFactor:G3})"; + default: return scale.ToString(); + } + } + + /// 露光を 1 段動かした目標値(direction: 正で上げる,負で下げる) + private static double NextExposure(IHwTestAdjustable adj, HwTestSetting current, int direction) { + return HwTestJudge.NextExposure(current.Value, current.Min, current.Max, adj.HwTestExposureScale, direction, + HwTestThresholds.ExposureLogStep, HwTestThresholds.ExposureDownFactor, HwTestThresholds.ExposureUpFactor); + } + + private static string DescribeSearchStop(HwTestBaselineSearchDecision decision) { + switch (decision) { + case HwTestBaselineSearchDecision.StopNotMeasured: return "平均輝度を測定できない"; + case HwTestBaselineSearchDecision.StopRangeEnd: return "露光が範囲の端で変わらない"; + case HwTestBaselineSearchDecision.StopMaxSteps: return $"{HwTestThresholds.BaselineSearchMaxSteps} 段動かしても届かない"; + case HwTestBaselineSearchDecision.StopOvershoot: return "1 段で中間の範囲(飽和しない明るさ)を飛び越えた"; + default: return decision.ToString(); + } + } + + /// + /// 検査の基準にする露光を探す.まず元の設定を測り(元に戻した後の比較用に session.BaselineAfterRestoreCheck へ記録する), + /// 露光が 2^n の段(Log2Step)なら最も近い 2^n に合わせ直して測る(以降の段との比が 2 倍・半分になるように). + /// その明るさが基準にできる明るさ(平均輝度が中間の範囲 HwTestThresholds.MidBrightnessLow〜High かつ飽和画素の割合がしきい値以下)でなければ, + /// 明るすぎる・飽和しているなら下げる向き,暗すぎるなら上げる向きに 1 段ずつ露光を動かし,基準にできる明るさになった設定を基準にする. + /// 見つからなければ WARN を出し,飽和していない段を優先して中間の範囲に最も近い段を基準にする. + /// + /// 基準にする露光(カメラから読み戻した値と範囲) + private static HwTestSetting FindBaselineExposure(Session session, SelfTestCheck c, IHwTestAdjustable adj, HwTestSetting original) { + var low = HwTestThresholds.MidBrightnessLow; + var high = HwTestThresholds.MidBrightnessHigh; + var maxSat = HwTestThresholds.MaxSaturatedFraction; + var samples = new List(); + + Settle(session, "基準探し: 元の設定"); + var m0 = MeasureBrightness(session, c, $"基準探し 元の設定(露光 {original.Value:G6})"); + session.BaselineAfterRestoreCheck = m0.Mean; + + // きりのよい段に合わせる(Log2Step のみ.Linear は元の値のまま).元の値への復元は RestoreSettings で元の値そのものに戻す + var current = original; + var start = m0; + var aligned = HwTestJudge.AlignExposure(original.Value, original.Min, original.Max, adj.HwTestExposureScale); + if (HwTestJudge.IsChanged(original.Value, aligned)) { + SetStep(session, $"基準探し: きりのよい段に合わせる({aligned:G6})"); + adj.HwTestSetExposure(aligned); + current = adj.HwTestGetExposure(); + Settle(session, "基準探し: きりのよい段"); + start = MeasureBrightness(session, c, $"基準探し 段 0(露光 {current.Value:G6}.元の {original.Value:G6} に最も近い 2^n に合わせた)"); + } else { + c.Info($"基準探し 段 0: 元の露光 {original.Value:G6} をそのまま使います(" + + (adj.HwTestExposureScale == HwTestValueScale.Log2Step ? "すでに 2^n の段" : "線形の量のため段に合わせない") + ")"); + } + samples.Add(new HwTestBaselineSample(current.Value, start.Mean, start.SaturatedFraction)); + + if (HwTestJudge.IsBaselineCandidate(start.Mean, start.SaturatedFraction, low, high, maxSat)) { + c.Info($"基準: 露光 {current.Value:G6} の平均輝度 {start.Mean:0.00}・飽和画素の割合 {start.SaturatedFraction:P2} が基準にできる明るさ" + + $"(中間の範囲 暫定 {low}〜{high},飽和 {maxSat:P0} 以下)のため,そのまま基準にします"); + return current; + } + var direction = HwTestJudge.BaselineSearchDirection(start.Mean, start.SaturatedFraction, low, high, maxSat); + if (direction == 0) { + c.Warn($"基準を中間に寄せられなかった(段 0 の平均輝度・飽和画素の割合を測定できない: {start.Mean:0.00} / {start.SaturatedFraction:P2})." + + $"露光 {current.Value:G6} を基準にして判定します"); + return current; + } + + var decision = HwTestBaselineSearchDecision.Continue; + for (var step = 1; decision == HwTestBaselineSearchDecision.Continue; step++) { + var target = NextExposure(adj, current, direction); + SetStep(session, $"基準探し 段 {step}: 露光を{(direction < 0 ? "下げる" : "上げる")}({target:G6})"); + adj.HwTestSetExposure(target); + var read = adj.HwTestGetExposure(); + var changed = HwTestJudge.IsChanged(current.Value, read.Value); + var m = HwTestMeasurement.NotMeasured; + if (changed) { + Settle(session, $"基準探し 段 {step}"); + m = MeasureBrightness(session, c, $"基準探し 段 {step}(露光 {read.Value:G6})"); + samples.Add(new HwTestBaselineSample(read.Value, m.Mean, m.SaturatedFraction)); + current = read; + } else { + c.Info($"基準探し 段 {step}: 露光が変わりませんでした(目標 {target:G6},読み戻し {read.Value:G6}.範囲の端)"); + } + decision = HwTestJudge.DecideBaselineSearch(m.Mean, m.SaturatedFraction, direction, changed, step, + HwTestThresholds.BaselineSearchMaxSteps, low, high, maxSat); + } + + var trail = string.Join(",", samples.Select(x => $"{x.Setting:G6}→{x.Mean:0.00}(飽和 {x.SaturatedFraction:P2})")); + c.Info($"基準探しの各段(露光→平均輝度): {trail}"); + if (decision == HwTestBaselineSearchDecision.Found) { + c.Info($"基準: 露光 {current.Value:G6}(平均輝度 {samples[samples.Count - 1].Mean:0.00}.中間の範囲 暫定 {low}〜{high},飽和 {maxSat:P0} 以下)"); + return current; + } + + var index = HwTestJudge.ChooseBaselineIndex(samples, low, high, maxSat); + var chosen = index >= 0 ? samples[index].Setting : current.Value; + c.Warn($"基準を中間に寄せられなかった({DescribeSearchStop(decision)}.各段の測定値: {trail}.中間の範囲 暫定 {low}〜{high},飽和 {maxSat:P0} 以下)." + + $"飽和していない段を優先して中間に最も近い露光 {chosen:G6} を基準にして判定します"); + if (HwTestJudge.IsChanged(current.Value, chosen)) { + SetStep(session, $"基準探し: 基準の露光にする({chosen:G6})"); + adj.HwTestSetExposure(chosen); + current = adj.HwTestGetExposure(); + } + return current; + } + + /// + /// 1 つの設定(露光またはゲイン)を下げる・上げるの順に変え,平均輝度の変化を criteria に従って判定する.最後に元の値(original)へ戻す. + /// + private static void CheckOneSetting(Session session, SelfTestCheck c, string name, + Func get, Action set, HwTestSetting original, double lowerTarget, double raiseTarget, + HwTestDirectionCriteria criteria) { + Settle(session, $"{name}: 基準"); + var baseline = MeasureBrightness(session, c, $"{name}: 基準({original.Value:G6})"); + + SetStep(session, $"{name}: 下げる({lowerTarget:G6})"); + set(lowerTarget); + var lowered = get().Value; + var lowerApplied = HwTestJudge.IsChanged(original.Value, lowered); + Settle(session, $"{name}: 下げた値"); + var lowerMean = lowerApplied ? MeasureBrightness(session, c, $"{name}: 下げた({lowered:G6})") : HwTestMeasurement.NotMeasured; + + SetStep(session, $"{name}: 上げる({raiseTarget:G6})"); + set(raiseTarget); + var raised = get().Value; + var raiseApplied = HwTestJudge.IsChanged(original.Value, raised); + Settle(session, $"{name}: 上げた値"); + var raiseMean = raiseApplied ? MeasureBrightness(session, c, $"{name}: 上げた({raised:G6})") : HwTestMeasurement.NotMeasured; + + SetStep(session, $"{name}: 元に戻す({original.Value:G6})"); + set(original.Value); + + if (!lowerApplied) c.Info($"{name}: 下げる設定が反映されませんでした(目標 {lowerTarget:G6},読み戻し {lowered:G6}.範囲の端の可能性)"); + if (!raiseApplied) c.Info($"{name}: 上げる設定が反映されませんでした(目標 {raiseTarget:G6},読み戻し {raised:G6}.範囲の端の可能性)"); + + var result = HwTestJudge.JudgeDirection(baseline, lowerMean, raiseMean, lowerApplied, raiseApplied, criteria); + var rule = criteria.UseRatio + ? $"比で判定: 下げる ≦ {criteria.LowerLimit:G3}・上げる ≧ {criteria.RaiseLimit:G3}(暫定.単純比)" + : $"差で判定: ±{criteria.LowerLimit:G3} 以上(暫定)"; + var policy = criteria.RequireAllChecked ? "確認した向きはすべて期待どおりであること" : "一方の向きだけでも期待どおりなら合格"; + c.Expect(result.Pass, + $"{name}: 平均輝度の変化が期待と異なります({result.Summary}.{rule},{policy})", + $"{name}: {result.Summary}({rule},{policy})"); + } + + /// + /// 露光・ゲインを元の値(最初に読んだ値そのもの.IScam は絶対値)に戻し,読み戻して確かめる(戻せたら RestorePending を下ろす). + /// 露光は相対誤差(HwTestThresholds.RestoreRelativeTolerance)で比べる.ゲインは元が 0 になりうるため, + /// 相対誤差と「範囲の幅 × HwTestThresholds.GainRestoreRangeFraction」の大きい方で比べる. + /// + private static void RestoreSettings(Session session, SelfTestCheck c) { + var adj = session.RestoreTarget; + if (adj == null || !session.RestorePending) return; + SetStep(session, "露光・ゲインを元に戻す"); + adj.HwTestSetExposure(session.OriginalExposure); + adj.HwTestSetGain(session.OriginalGain); + var exposure = adj.HwTestGetExposure().Value; + var gain = adj.HwTestGetGain().Value; + var rel = HwTestThresholds.RestoreRelativeTolerance; + var exposureOk = HwTestJudge.IsSameSetting(session.OriginalExposure, exposure, rel, 0); + var gainOk = HwTestJudge.IsSameSetting(session.OriginalGain, gain, rel, + session.OriginalGainRangeWidth * HwTestThresholds.GainRestoreRangeFraction); + var ok = exposureOk && gainOk; + c.Expect(ok, + $"露光・ゲインが元の値に戻りません(露光 {exposure:G6},元 {session.OriginalExposure:G6}{(exposureOk ? "" : "(不一致)")}" + + $"/ゲイン {gain:G6},元 {session.OriginalGain:G6}{(gainOk ? "" : "(不一致)")}.相対誤差の許容 {rel:P0})", + $"露光・ゲインを元の値に戻しました(露光 {exposure:G6},元 {session.OriginalExposure:G6}/ゲイン {gain:G6},元 {session.OriginalGain:G6}.相対誤差の許容 {rel:P0})"); + c.Info(HwTestJudge.DescribeRestoredValue("露光", session.OriginalExposure, exposure) + + "/" + HwTestJudge.DescribeRestoredValue("ゲイン", session.OriginalGain, gain)); + if (ok) session.RestorePending = false; + } + + // ================================================================================ + // light + // ================================================================================ + + private static void CheckLight(Session session, SelfTestCheck c) { + var form = session.Form; + if (!c.Expect(form.HwTestLightIsOpen, + $"光源のポートが開いていません(光源が接続されていない,または config.xml の LightSource/Device「{Config.GetString("LightSource/Device")}」に合うポートが見つからない)")) return; + ExpectCalibrationStopped(session, c); + c.Expect(form.HwTestLightOn, "検査の開始時に光源が点灯状態になっていません"); + + Settle(session, "点灯"); + var on1 = MeasureBrightness(session, c, "点灯").Mean; + + SetStep(session, "照明ボタン(消灯)"); + form.HwTestClickLightButton(); + c.Expect(!form.HwTestLightOn && form.HwTestLightButtonText == "照明 ON", + $"照明ボタンで消灯状態になりません(状態 {(form.HwTestLightOn ? "点灯" : "消灯")},表示「{form.HwTestLightButtonText}」)"); + Settle(session, "消灯"); + var off = MeasureBrightness(session, c, "消灯").Mean; + + SetStep(session, "照明ボタン(点灯)"); + form.HwTestClickLightButton(); + c.Expect(form.HwTestLightOn && form.HwTestLightButtonText == "照明 OFF", + $"照明ボタンで点灯状態に戻りません(状態 {(form.HwTestLightOn ? "点灯" : "消灯")},表示「{form.HwTestLightButtonText}」)"); + Settle(session, "再点灯"); + var on2 = MeasureBrightness(session, c, "再点灯").Mean; + + c.Expect(HwTestJudge.JudgeLight(on1, off, HwTestThresholds.MinLightDelta), + $"点灯と消灯の平均輝度の差が下限(暫定 {HwTestThresholds.MinLightDelta})未満です: {on1 - off:0.00}(点灯 {on1:0.00},消灯 {off:0.00})", + $"点灯と消灯の差: {on1 - off:0.00}"); + c.Expect(HwTestJudge.JudgeLight(on2, off, HwTestThresholds.MinLightDelta), + $"再点灯と消灯の平均輝度の差が下限(暫定 {HwTestThresholds.MinLightDelta})未満です: {on2 - off:0.00}(再点灯 {on2:0.00},消灯 {off:0.00})", + $"再点灯と消灯の差: {on2 - off:0.00}"); + + SetStep(session, "光源のポートを閉じる(LightSource.Close)"); + form.HwTestCloseLight(); + c.Expect(!form.HwTestLightIsOpen, "LightSource.Close の後もポートが開いています", "ポートを閉じました(消灯はしない)"); + + SetStep(session, "光源のポートを開き直す(LightSource.Open)"); + var reopened = form.HwTestReopenLight(); + if (!c.Expect(reopened && form.HwTestLightIsOpen, "閉じた後にポートを開き直せません(LightSource.Open が false)", "ポートを開き直して点灯しました")) return; + Settle(session, "開き直して点灯"); + var on3 = MeasureBrightness(session, c, "開き直して点灯").Mean; + c.Expect(HwTestJudge.JudgeLight(on3, off, HwTestThresholds.MinLightDelta), + $"開き直した後の点灯が平均輝度に効いていません: 差 {on3 - off:0.00}(点灯 {on3:0.00},消灯 {off:0.00})", + $"開き直した後の点灯と消灯の差: {on3 - off:0.00}"); + } + + // ================================================================================ + // close-preview + // ================================================================================ + + private static void CheckClosePreview(Session session, SelfTestCheck c) { + var form = session.Form; + if (!c.Expect(session.CameraConnected, "カメラが接続されていません")) return; + + SetStep(session, "閉じる前のフレームの確認"); + var before = form.HwTestFrameCount; + SelfTestPump.PumpUntil(() => form.HwTestFrameCount > before, TimeSpan.FromSeconds(3)); + c.Expect(form.HwTestFrameCount > before, "閉じる前にプレビューが流れていません(プレビュー中に閉じる検査になりません)"); + + SetStep(session, "閉じる処理(Form1_FormClosing → ShutdownOnClose)"); + var sw = Stopwatch.StartNew(); + bool closed = false; + Exception error = null; + try { + closed = form.HwTestRequestClose(); + } catch (Exception ex) { + error = ex; + } + var elapsed = sw.Elapsed; + var steps = form.HwTestCloseSteps; + // 後始末がカメラの切断まで進んだかで,以降の切断の要否を決める + if (steps.Contains("camera")) session.CameraConnected = false; + + c.Info($"閉じる処理の所要時間: {elapsed.TotalMilliseconds:0} ms,通過した段階: {string.Join(" → ", steps)}"); + if (error != null) c.FailException("閉じる処理で例外", error); + 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" }), + $"後始末の順序が不正です: {string.Join(" → ", steps)}(期待 closing → timer → camera → light)"); + c.Expect(form.HwTestIsClosing, "閉じ始めのフラグが立っていません"); + c.Expect(!form.HwTestRefreshTimerRunning, "本モニタの再描画タイマーが止まっていません"); + c.Expect(!form.HwTestLightIsOpen, "閉じた後も光源のポートが開いています"); + + SetStep(session, "閉じた後にフレームが止まったかの確認"); + var afterStart = form.HwTestFrameCount; + SelfTestPump.PumpFor(HwTestThresholds.AfterCloseObserve); + var afterFrames = form.HwTestFrameCount - afterStart; + c.Expect(afterFrames <= HwTestThresholds.MaxFramesAfterClose, + $"閉じた後もフレームが届いています: {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒で {afterFrames} フレーム(上限 暫定 {HwTestThresholds.MaxFramesAfterClose})", + $"閉じた後 {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒間に届いたフレーム: {afterFrames}"); + } + + // ================================================================================ + // reconnect + // ================================================================================ + + private static void CheckReconnect(Session session, SelfTestCheck c) { + var form = session.Form; + if (session.CameraConnected) { + SetStep(session, "切断(Disconnect)"); + var sw0 = Stopwatch.StartNew(); + form.HwTestDisconnectCamera(); + session.CameraConnected = false; + c.Info($"切断: {sw0.ElapsedMilliseconds} ms"); + } else { + c.Info("閉じる処理で切断済みのカメラに再接続します"); + } + + SetStep(session, "Connect(2 回目)"); + var f0 = form.HwTestFrameCount; + var sw = Stopwatch.StartNew(); + var camera = form.HwTestConnect(session.Options.Camera); + var connectErrors = form.HwTestTakeConnectErrors(); + if (!c.Expect(camera != null, $"2 回目の Connect() に失敗しました({string.Join(" / ", connectErrors)})")) return; + session.Camera = camera; + session.CameraConnected = true; + c.Info($"2 回目の Connect(): {sw.ElapsedMilliseconds} ms(機種名 {camera.DeviceName},シリアル番号 {camera.SerialNumber})"); + c.Expect(camera.SerialNumber == session.FirstSerial, + $"再接続したカメラのシリアル番号が 1 回目と異なります: {camera.SerialNumber}(1 回目 {session.FirstSerial})"); + if (session.RestorePending) { + // 接続時に露光・ゲインは設定ファイル(Lucam: config.xml,IScam: デバイス状態ファイル)の値に設定し直される + // (1 回目の接続時と同じ値).切断済みの前のインスタンスに対して戻す必要はない + c.Info("再接続で露光・ゲインが設定ファイルの値に戻ったため,検査で変えた値の復元は不要になりました"); + session.RestorePending = false; + } + + SetStep(session, "再接続後のフレームの待機"); + var arrived = SelfTestPump.PumpUntil(() => form.HwTestFrameCount - f0 >= HwTestThresholds.ReconnectMinFrames, + HwTestThresholds.ReconnectFrameTimeout); + var frames = form.HwTestFrameCount - f0; + c.Expect(arrived, + $"再接続後 {HwTestThresholds.ReconnectFrameTimeout.TotalSeconds:0} 秒以内に {HwTestThresholds.ReconnectMinFrames} フレーム届きません({frames} フレーム)", + $"再接続後 {sw.ElapsedMilliseconds} ms で {frames} フレーム到着"); + } + + // ================================================================================ + // cleanup + // ================================================================================ + + /// + /// 後始末.順序は「変えた露光・ゲインを元に戻す → カメラ切断 → 光源のポートを閉じる」. + /// 光源は消灯しない(アプリの終了時と同じく点いたまま残る.上流 v1.7 と同じ). + /// 各段階は前の段階が失敗しても実行する.最後に設定・デバイス状態ファイル・本番の保存先を書き換えていないことを確かめる. + /// + private static void CheckCleanup(Session session, SelfTestCheck c, FileSnapshot snapshot, SaveFolderSnapshot userSaveBefore) { + RunCleanup(session, c); + + SetStep(session, "設定・デバイス状態ファイルの確認"); + foreach (var change in snapshot.Compare(FileSnapshot.Take(session.BaseDir))) { + c.Fail($"実行ファイルのフォルダの設定ファイルが変わりました(書き込んではならない): {change}"); + } + c.Info($"設定・デバイス状態ファイル {snapshot.Count} 件({snapshot.Names}): 変更なし(変更があれば上に FAIL)"); + + var userSaveAfter = SaveFolderSnapshot.Take(session.UserSaveFolder); + c.Expect(userSaveBefore.SameAs(userSaveAfter), + $"本番の保存先({session.UserSaveFolder})の今日のフォルダが変わりました: {userSaveBefore} → {userSaveAfter}", + $"本番の保存先({session.UserSaveFolder}): 変更なし({userSaveAfter})"); + } + + private static void RunCleanup(Session session, SelfTestCheck c) { + if (session.CleanupDone) return; + session.CleanupDone = true; + var form = session.Form; + + if (session.RestorePending && !session.CameraConnected) { + c.Warn($"カメラが切断済みのため露光・ゲインを元に戻せません(元の値: 露光 {session.OriginalExposure:G6},ゲイン {session.OriginalGain:G6}.次の接続で設定ファイルの値に戻ります)"); + } else if (session.RestorePending) { + try { + RestoreSettings(session, c); + } catch (Exception ex) { + c.FailException("露光・ゲインを元に戻せませんでした", ex); + } + } else { + c.Info("露光・ゲイン: 変更なし(または検査中に元に戻し済み)"); + } + + if (session.CameraConnected) { + SetStep(session, "カメラ切断"); + try { + form.HwTestDisconnectCamera(); + session.CameraConnected = false; + c.Info("カメラを切断しました"); + } catch (Exception ex) { + c.FailException("カメラの切断に失敗しました", ex); + } + } else { + c.Info("カメラ: 切断済み"); + } + + if (form != null) { + SetStep(session, "光源のポートを閉じる"); + try { + form.HwTestCloseLight(); + c.Expect(!form.HwTestLightIsOpen, "光源のポートが閉じていません", "光源: ポートを閉じました(消灯はしない.開いていなければ何もしない)"); + } catch (Exception ex) { + c.FailException("光源を閉じられませんでした", ex); + } + } + } + + /// 項目の外で例外になったときの後始末(記録は結果ファイルに直接書く) + private static void RunCleanupSafely(Session session) { + var check = new SelfTestCheck("cleanup", "後始末(異常終了時)"); + try { + RunCleanup(session, check); + } catch (Exception ex) { + check.FailException("後始末の失敗", ex); + } + session.Report.WriteCheck(check); + } + + private static void CleanupWorkDir(SelfTestReport report, string workDir, bool keep) { + if (!Directory.Exists(workDir)) return; + if (keep) { + report.WriteLine($"work dir kept: {workDir}"); + return; + } + try { + Directory.Delete(workDir, true); + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { + report.WriteLine($"WARN: 作業フォルダを削除できませんでした(手動で削除してください): {workDir}: {ex.Message}"); + } + } + + // ================================================================================ + // 書き込んではならないファイルの確認 + // ================================================================================ + + /// 実行ファイルのフォルダの *.xml(config.xml・デバイス状態ファイル等)の内容の記録 + private sealed class FileSnapshot { + private readonly Dictionary _hashes; + + private FileSnapshot(Dictionary hashes) { + _hashes = hashes; + } + + public int Count => _hashes.Count; + + public string Names => string.Join(", ", _hashes.Keys.OrderBy(x => x)); + + public static FileSnapshot Take(string dir) { + var hashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var path in Directory.GetFiles(dir, "*.xml")) { + hashes[Path.GetFileName(path)] = Hash(path); + } + return new FileSnapshot(hashes); + } + + public IEnumerable Compare(FileSnapshot after) { + foreach (var kv in _hashes) { + if (!after._hashes.TryGetValue(kv.Key, out var h)) { + yield return $"{kv.Key} が無くなりました"; + } else if (h != kv.Value) { + yield return $"{kv.Key} の内容が変わりました"; + } + } + foreach (var key in after._hashes.Keys) { + if (!_hashes.ContainsKey(key)) yield return $"{key} が作られました"; + } + } + + private static string Hash(string path) { + using (var sha = SHA256.Create()) + using (var stream = File.OpenRead(path)) { + return BitConverter.ToString(sha.ComputeHash(stream)); + } + } + } + + /// 本番の保存先の今日の日付フォルダ(CreateSaveFolder が作る場所)の有無と中身の数の記録 + private sealed class SaveFolderSnapshot { + private bool _exists; + private int _entries; + private string _path; + + public static SaveFolderSnapshot Take(string saveFolder) { + var s = new SaveFolderSnapshot(); + if (string.IsNullOrEmpty(saveFolder)) return s; + s._path = Path.Combine(saveFolder, DateTime.Now.ToString("yyyy-MM-dd")); + try { + s._exists = Directory.Exists(s._path); + s._entries = s._exists ? Directory.GetFileSystemEntries(s._path).Length : 0; + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { + Debug.WriteLine($"[HWTEST] 保存先を確認できません: {s._path}: {ex.Message}"); + s._entries = -1; + } + return s; + } + + public bool SameAs(SaveFolderSnapshot other) { + return _exists == other._exists && _entries == other._entries; + } + + public override string ToString() { + return _exists ? $"{_path} に {_entries} 件" : $"{_path ?? "(保存先なし)"} なし"; + } + } + } +} +#endif diff --git a/TIASshot/HwTest/IHwTestAdjustable.cs b/TIASshot/HwTest/IHwTestAdjustable.cs new file mode 100644 index 0000000..36cb14b --- /dev/null +++ b/TIASshot/HwTest/IHwTestAdjustable.cs @@ -0,0 +1,66 @@ +#if HWTEST +namespace TIASshot { + + /// + /// カメラの設定値(露光・ゲイン)の現在値と設定可能な範囲(HWTEST 限定). + /// 単位は機種ごとの SDK の値そのまま(Lucam: 露光 ms・ゲイン倍率,IScam: VCDAbsoluteValueProperty の絶対値(露光は秒.ゲインは絶対値が無い機種では VCDRangeProperty の整数値)). + /// 元の値へ正確に戻せるよう,丸めの無い(または機種の分解能どおりの)値で読み書きする. + /// + internal struct HwTestSetting { + public double Value; + public double Min; + public double Max; + + public HwTestSetting(double value, double min, double max) { + Value = value; + Min = min; + Max = max; + } + + public override string ToString() { + return $"{Value:G6}(範囲 {Min:G6}〜{Max:G6})"; + } + } + + /// + /// 設定値の種類(HWTEST 限定).検査で値を 1 段動かすときの動かし方を決める. + /// 推測で判定せず,機種ごとのアダプタ(Lucam.HwTest・IScam.HwTest)で明示する. + /// + internal enum HwTestValueScale { + /// 線形の量(値が時間などの量そのもの.1 段 = 現在値の ×0.5 / ×2.きりのよい段には合わせない) + Linear, + /// + /// 値は時間などの線形の量だが,検査では 2^n のきりのよい段で動かす(基準探しの前に最も近い 2^n に合わせ,1 段 = 2^(n±1) = 時間で 2 倍・半分). + /// 元の値への復元は段ではなく元の値そのもので行う + /// + Log2Step, + } + + /// + /// 実機テストで露光・ゲインを読み書きする口(HWTEST 限定.Lucam・IScam の HwTest 部分で実装する). + /// 書き込みは SDK を呼ぶだけの薄い処理とし,試す値の決定や判定は HwTestJudge(純粋関数)が行う. + /// + internal interface IHwTestAdjustable { + /// 露光の値の単位の説明(結果ファイルに出す) + string HwTestExposureUnit { get; } + + /// 露光の値の種類(線形の量か対数の段か).検査で露光を 1 段動かすときの動かし方に使う + HwTestValueScale HwTestExposureScale { get; } + + /// ゲインの値の単位の説明(結果ファイルに出す) + string HwTestGainUnit { get; } + + /// 露光の現在値(カメラから読む)と範囲 + HwTestSetting HwTestGetExposure(); + + /// 露光を設定する(範囲外は範囲内に収める) + void HwTestSetExposure(double value); + + /// ゲインの現在値(カメラから読む)と範囲 + HwTestSetting HwTestGetGain(); + + /// ゲインを設定する(範囲外は範囲内に収める) + void HwTestSetGain(double value); + } +} +#endif diff --git a/TIASshot/LightSource.cs b/TIASshot/LightSource.cs index f72725d..13f5cba 100644 --- a/TIASshot/LightSource.cs +++ b/TIASshot/LightSource.cs @@ -61,18 +61,13 @@ } /// - /// 光源を消してポートを閉じる(アプリ終了時に呼ぶ).開いていなければ何もしない. - /// 終了処理を止めないよう,消灯・クローズの失敗は捕捉してログのみ残す - /// (消灯に失敗してもポートは閉じる). + /// 消灯せずにポートを閉じる(アプリ終了時に呼ぶ).開いていなければ何もしない. + /// 終了後も光源は点いたまま残る(上流 v1.7 と同じ.消灯の命令は送らない). + /// 終了処理を止めないよう,クローズの失敗は捕捉してログのみ残す. /// public void Close() { if (!_serial.IsOpen) return; try { - TurnOff(); - } catch (Exception ex) { - Debug.WriteLine($"光源の消灯に失敗しました: {ex}"); - } - try { _serial.Close(); } catch (Exception ex) { Debug.WriteLine($"光源のポートを閉じられませんでした: {ex}"); diff --git a/TIASshot/Program.cs b/TIASshot/Program.cs index f875009..dff92eb 100644 --- a/TIASshot/Program.cs +++ b/TIASshot/Program.cs @@ -22,6 +22,15 @@ return; } #endif +#if HWTEST + // 実機テスト(--hwtest).実際の Form1 を表示し,指定した機種のカメラと光源を操作して検査し,終了コードで結果を返す + var hwArgs = Environment.GetCommandLineArgs(); + if (HwTestRunner.IsRequested(hwArgs)) { + // カメラ SDK のスレッド等が残っても確実に終了コードを返すため Exit で抜ける + Environment.Exit(HwTestRunner.Run(hwArgs)); + return; + } +#endif Application.Run(new Form1()); } } diff --git a/TIASshot/SelfTest/SelfTestConfig.cs b/TIASshot/SelfTest/SelfTestConfig.cs index 5c27286..620e7bf 100644 --- a/TIASshot/SelfTest/SelfTestConfig.cs +++ b/TIASshot/SelfTest/SelfTestConfig.cs @@ -41,12 +41,13 @@ /// 値が null の項目は要素ごと削除する(設定欠落の注入用).標準の差し替えより後に適用する. /// 存在しないキーに値を設定しようとした場合は InvalidDataException(キーの打ち間違いの検出). /// + /// 作業フォルダに書き出す一時設定ファイルの名前.null のときは (実機テストは別名にする) /// 準備した一時設定の情報 /// ファイル・フォルダの読み書きに失敗した場合 /// 複製元が XML として読めない場合 /// 必要な要素が無い場合 public static Prepared Prepare(string sourceConfig, string refBaseDir, string workDir, - IDictionary overrides = null) { + IDictionary overrides = null, string fileName = null) { Directory.CreateDirectory(workDir); var doc = new XmlDocument(); doc.Load(sourceConfig); @@ -78,7 +79,7 @@ } } - result.ConfigPath = Path.Combine(workDir, FileName); + result.ConfigPath = Path.Combine(workDir, fileName ?? FileName); doc.Save(result.ConfigPath); Config.Load(result.ConfigPath); return result; diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index 71a3cd3..4be5f51 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -22,7 +22,7 @@ full false bin\Debug\ - DEBUG;TRACE;WHITEBOARD;MEMMONITOR;SELFTEST + DEBUG;TRACE;WHITEBOARD;MEMMONITOR;SELFTEST;HWTEST prompt 4 true @@ -40,7 +40,7 @@ true bin\x64\Debug\ - DEBUG;TRACE;WHITEBOARD;MEMMONITOR;SELFTEST + DEBUG;TRACE;WHITEBOARD;MEMMONITOR;SELFTEST;HWTEST full x64 7.3 @@ -124,6 +124,7 @@ + @@ -145,6 +146,9 @@ Form1.cs + + Form1.cs + Form @@ -171,6 +175,12 @@ + + + + + + diff --git a/TIASshot/UI/Form1.HwTest.cs b/TIASshot/UI/Form1.HwTest.cs new file mode 100644 index 0000000..84f28d2 --- /dev/null +++ b/TIASshot/UI/Form1.HwTest.cs @@ -0,0 +1,283 @@ +#if HWTEST +#if !SELFTEST +#error HWTEST は SELFTEST の部品(SelfTestConfig・SelfTestReport・SelfTestPump・閉じる処理の段階の記録)を使うため,SELFTEST と同時に定義すること +#endif +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows.Forms; + +namespace TIASshot { + public partial class Form1 { + + /// + /// 実機テスト用コンストラクタの目印型(HWTEST 限定). + /// + internal sealed class HwTestMarker { + internal static readonly HwTestMarker Instance = new HwTestMarker(); + private HwTestMarker() { } + } + + // 実機テストのモードか(Form1_Load の自動接続をしない・ShowError をモーダル表示しない・メモリ観測窓を開かない) + private bool _hwTestMode = false; + + // ShowImage に届いたフレームの数(コールバックスレッドが書き,UI スレッドが読む) + private long _hwTestFrames = 0; + private int _hwTestFrameWidth = 0; + private int _hwTestFrameHeight = 0; + // フレームの記録中の例外(最初の 1 件) + private string _hwTestFrameError = null; + + // 平均輝度・飽和画素の割合の測定の要求と結果(_hwTestSampleLock で保護) + private readonly object _hwTestSampleLock = new object(); + private int _hwTestSamplesWanted = 0; + private readonly List _hwTestSamples = new List(); + + // ShowError に渡されたメッセージ(_hwTestSampleLock で保護) + private readonly List _hwTestErrors = new List(); + + // 直近の接続失敗の理由(_hwTestSampleLock で保護) + private readonly List _hwTestConnectErrors = new List(); + + /// + /// 実機テスト用コンストラクタ(HWTEST 限定).通常のコンストラクタ(InitializeComponent を含む)を通したうえで, + /// 実機テストのモードにする.表示(Show)すると Form1_Load が走るが,自動接続はしない(HwTestConnect で機種を指定して接続する). + /// + internal Form1(HwTestMarker marker) : this() { + _hwTestMode = true; + Text += " [HWTEST]"; + } + + // ================================================================================ + // カメラ・光源の接続 + // ================================================================================ + + /// 接続されている Lumenera カメラの台数(SDK が無い・列挙に失敗した場合は例外) + internal static int HwTestCountLucam() { + return Lucam.HwTestCountCameras(); + } + + /// 見つかった ImagingSource カメラの名前(IC Imaging Control の列挙) + internal string[] HwTestListIScam() { + return icImagingControl1.Devices.Select(d => d.Name).ToArray(); + } + + /// + /// 指定した機種だけを接続する(通常起動の Form1_Load と同じ接続処理を使う.ただし Lucam 優先の自動選択はしない). + /// 接続できたら現在のカメラ(_camera)にする. + /// + /// 接続できたカメラ.できなければ null + internal CameraBase HwTestConnect(HwTestCameraKind kind) { + var camera = kind == HwTestCameraKind.Lucam ? ConnectLucam() : ConnectIScam(); + _camera = camera; + return camera; + } + + /// + /// 接続後の画面・光源・副モニタ・本モニタの再描画タイマーの開始(通常起動の Form1_Load と同じ処理). + /// + internal void HwTestStartSession() { + StartSession(); + } + + partial void OnConnectFailed(string device, string error) { + lock (_hwTestSampleLock) { + _hwTestConnectErrors.Add($"{device}: {error}"); + } + } + + /// 接続失敗の理由を取り出す(古い順.取り出した分は消す) + internal List HwTestTakeConnectErrors() { + lock (_hwTestSampleLock) { + var result = _hwTestConnectErrors.ToList(); + _hwTestConnectErrors.Clear(); + return result; + } + } + + /// 現在のカメラ(未接続なら null) + internal CameraBase HwTestCamera => _camera; + + /// 現在のカメラを切断する(閉じる処理を通さない.再接続の検査・後始末用) + internal void HwTestDisconnectCamera() { + _camera?.Disconnect(); + } + + /// 光源のポートが開いているか + internal bool HwTestLightIsOpen => _light.IsOpen; + + /// 光源の点灯状態(画面の照明ボタンが管理する状態) + internal bool HwTestLightOn => _isLightOn; + + /// 照明ボタンの表示文 + internal string HwTestLightButtonText => btnLightSW.Text; + + /// 照明ボタンを押す(通常の操作と同じ処理.校正中は何もしない) + internal void HwTestClickLightButton() { + btnLightSW_Click(btnLightSW, EventArgs.Empty); + } + + /// 光源のポートを閉じる(閉じる処理と同じ LightSource.Close.消灯はしない) + internal void HwTestCloseLight() { + _light.Close(); + } + + /// + /// 光源のポートを開き直して点灯する(Form1_Load と同じ手順).画面の照明ボタンの状態も点灯に合わせる. + /// + /// 開けたら true + internal bool HwTestReopenLight() { + if (!_light.Open()) return false; + _light.SetLight(); + _isLightOn = true; + btnLightSW.Text = "照明 OFF"; + btnLightSW.Enabled = true; + return true; + } + + /// + /// ウォッチドッグ(別スレッド)からの光源のポートの緊急クローズ(タイムアウト時の後始末). + /// 消灯はしない(終了時と同じく光源は点いたまま残る).LightSource.Close はクローズの失敗をログに残して戻る. + /// + internal void HwTestEmergencyCloseLight() { + _light.Close(); + } + + // ================================================================================ + // 閉じる処理 + // ================================================================================ + + /// + /// ユーザーが閉じようとしたときの処理(Form1_FormClosing)を呼ぶ(UI スレッドで呼ぶこと). + /// 閉じると決まった場合は後始末(閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源)まで行う. + /// フォームは破棄しない(後続の再接続の検査で使う). + /// + /// 閉じると決まったら true,取り消されたら(保存中)false + internal bool HwTestRequestClose() { + var e = new FormClosingEventArgs(CloseReason.UserClosing, false); + Form1_FormClosing(this, e); + return !e.Cancel; + } + + /// 閉じるときの後始末が通過した段階(SELFTEST の記録を使う) + internal IReadOnlyList HwTestCloseSteps => SelfTestCloseSteps; + + /// 閉じ始めたか + internal bool HwTestIsClosing => _isClosing; + + /// 本モニタの再描画タイマーが動作中か + internal bool HwTestRefreshTimerRunning => _mainRefreshTimer.Enabled; + + /// 本モニタ(picDisplay)に表示中の画像 + internal Image HwTestDisplayedImage => picDisplay.Image; + + // ================================================================================ + // フレームの観測 + // ================================================================================ + + /// ShowImage に届いたフレームの数 + internal long HwTestFrameCount => Interlocked.Read(ref _hwTestFrames); + + /// 最後に届いたフレームの大きさ + internal Size HwTestLastFrameSize => new Size(Volatile.Read(ref _hwTestFrameWidth), Volatile.Read(ref _hwTestFrameHeight)); + + /// フレームの記録中に起きた例外(無ければ null) + internal string HwTestFrameError => Volatile.Read(ref _hwTestFrameError); + + /// ShowError に渡されたメッセージ(古い順のコピー) + internal IReadOnlyList HwTestErrors { + get { lock (_hwTestSampleLock) return _hwTestErrors.ToArray(); } + } + + /// + /// 次に届く count 枚のフレームの平均輝度・飽和画素の割合の測定を要求する(それまでの測定結果は捨てる). + /// + internal void HwTestRequestSamples(int count) { + lock (_hwTestSampleLock) { + _hwTestSamples.Clear(); + _hwTestSamplesWanted = count; + } + } + + /// 測定済みのフレーム数 + internal int HwTestSampleCount { + get { lock (_hwTestSampleLock) return _hwTestSamples.Count; } + } + + /// 測定結果を取り出す(未達の要求は取り消す) + internal List HwTestTakeSamples() { + lock (_hwTestSampleLock) { + _hwTestSamplesWanted = 0; + var result = _hwTestSamples.ToList(); + _hwTestSamples.Clear(); + return result; + } + } + + /// + /// ShowImage(カメラのコールバックスレッド)から呼ばれるフレームの記録.入力 bmp は読むだけ(所有権は呼び出し側). + /// 例外をコールバックへ伝えるとプレビューが止まるため,記録して戻る(実機テストの結果で FAIL として報告する). + /// + private void HwTestOnFrame(Bitmap bmp) { + if (!_hwTestMode) return; + try { + Interlocked.Increment(ref _hwTestFrames); + Volatile.Write(ref _hwTestFrameWidth, bmp.Width); + Volatile.Write(ref _hwTestFrameHeight, bmp.Height); + + bool wanted; + lock (_hwTestSampleLock) wanted = _hwTestSamplesWanted > 0; + if (!wanted) return; + + var stats = MeasureFrameStats(bmp); + lock (_hwTestSampleLock) { + if (_hwTestSamplesWanted > 0) { + _hwTestSamples.Add(stats); + _hwTestSamplesWanted--; + } + } + } catch (Exception ex) { + Debug.WriteLine($"[HWTEST] フレームの記録に失敗しました: {ex}"); + Interlocked.CompareExchange(ref _hwTestFrameError, $"{ex.GetType().FullName}: {ex.Message}", null); + } + } + + /// + /// フレームの平均輝度と飽和画素の割合を求める.間引いた行だけを複製して純粋関数(HwTestJudge.FrameStats)に渡す. + /// + private static HwTestFrameStats MeasureFrameStats(Bitmap bmp) { + var bytesPerPixel = Image.GetPixelFormatSize(bmp.PixelFormat) / 8; + if (bytesPerPixel < 1 || bytesPerPixel > 4) { + throw new NotSupportedException($"平均輝度を求められない画素形式です: {bmp.PixelFormat}"); + } + var step = HwTestThresholds.PixelStep; + var data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, bmp.PixelFormat); + try { + var rowCount = (bmp.Height + step - 1) / step; + var rowBytes = bmp.Width * bytesPerPixel; + var rows = new byte[(long)rowCount * rowBytes]; + for (var r = 0; r < rowCount; r++) { + var src = IntPtr.Add(data.Scan0, r * step * data.Stride); + Marshal.Copy(src, rows, r * rowBytes, rowBytes); + } + return HwTestJudge.FrameStats(rows, rowCount, bmp.Width, rowBytes, bytesPerPixel, step, HwTestThresholds.SaturatedPixelLevel); + } finally { + bmp.UnlockBits(data); + } + } + + /// ShowError の実機テスト版(モーダル表示すると無人実行が止まるため記録のみ行う) + private void HwTestRecordError(string msg) { + Debug.WriteLine($"[HWTEST] ShowError: {msg}"); + lock (_hwTestSampleLock) { + _hwTestErrors.Add(msg); + } + } + } +} +#endif diff --git a/TIASshot/UI/Form1.MemMonitor.cs b/TIASshot/UI/Form1.MemMonitor.cs index d65d166..93a2065 100644 --- a/TIASshot/UI/Form1.MemMonitor.cs +++ b/TIASshot/UI/Form1.MemMonitor.cs @@ -16,6 +16,10 @@ /// Shown イベントから呼び出すことでメインフォームの最終位置を確定させてから配置する。 /// private void ShowMemoryMonitor() { +#if HWTEST + // 実機テスト(--hwtest)では開かない(計測 CSV の書き出しと計測負荷で検査に影響させないため) + if (_hwTestMode) return; +#endif if (_memoryMonitorForm != null && !_memoryMonitorForm.IsDisposed) { _memoryMonitorForm.BringToFront(); return; diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index 3667e62..da04a4c 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -72,25 +72,15 @@ /// /// private void Form1_Load(object sender, EventArgs e) { +#if HWTEST + // 実機テスト(--hwtest)では設定の読み込み(同梱 config.xml)と自動接続(Lucam 優先)を行わない. + // 実機テストのランナーが一時設定を読み込んだうえで,指定した機種だけを接続する(Form1.HwTest.cs) + if (_hwTestMode) return; +#endif Config.Load(); - _camera = new Lucam(this, picPreview); - if (!_camera.Connect()) { - Debug.WriteLine(_camera.ErrorMsg); - Debug.WriteLine("Lucamの接続に失敗しました"); - _camera = null; - } - - if (_camera == null) { - var cameraList = icImagingControl1.Devices.Select(d => d.Name.Replace(" ", "")); - foreach (var cam in cameraList) { - _camera = new IScam(this, icImagingControl1, cam); - if (_camera.Connect()) break; - Debug.WriteLine(_camera.ErrorMsg); - Debug.WriteLine($"{cam}の接続に失敗しました"); - _camera = null; - } - } + // Lucam を優先し,見つからなければ ImagingSource を順に試す + _camera = ConnectLucam() ?? ConnectIScam(); if (_camera == null) { MessageBox.Show("カメラが見つかりません.終了します.", APP_NAME, @@ -99,6 +89,50 @@ return; } + StartSession(); + } + + /// + /// Lumenera カメラを接続する. + /// + /// 接続できたカメラ.接続できなければ null + private CameraBase ConnectLucam() { + var camera = new Lucam(this, picPreview); + if (camera.Connect()) return camera; + Debug.WriteLine(camera.ErrorMsg); + Debug.WriteLine("Lucamの接続に失敗しました"); + OnConnectFailed("Lucam", camera.ErrorMsg); + return null; + } + + /// + /// ImagingSource カメラを,見つかった順に接続できるまで試す. + /// + /// 接続できたカメラ.接続できなければ null + private CameraBase ConnectIScam() { + var cameraList = icImagingControl1.Devices.Select(d => IScamDeviceSelector.ToDeviceKey(d.Name)); + foreach (var cam in cameraList) { + var camera = new IScam(this, icImagingControl1, cam); + if (camera.Connect()) return camera; + Debug.WriteLine(camera.ErrorMsg); + Debug.WriteLine($"{cam}の接続に失敗しました"); + OnConnectFailed(cam, camera.ErrorMsg); + } + return null; + } + + /// + /// カメラの接続に失敗したことの記録(HWTEST でのみ実装.Release では呼び出しごと消える). + /// + /// 機種名(Lucam またはデバイス名) + /// 失敗の理由(CameraBase.ErrorMsg) + partial void OnConnectFailed(string device, string error); + + /// + /// カメラ接続後の画面・光源・副モニタ・本モニタの再描画タイマーの開始(Form1_Load から呼ぶ. + /// 実機テストでも同じ手順を通すため切り出している). + /// + private void StartSession() { txtDeviceName.Text = _camera.DeviceName; txtSerialNo.Text = _camera.SerialNumber; txtNumMultiShots.Text = Config.GetString("Shot/MultiShotCount"); @@ -150,7 +184,8 @@ /// /// 閉じるときの後始末(Form1_FormClosing で閉じると決めた後に呼ぶ). - /// 【重要】順序は「閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源を消してポートを閉じる」を守ること. + /// 【重要】順序は「閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源のポートを閉じる」を守ること + /// (光源は消灯しない.終了後も点いたまま残るのが仕様で,上流 v1.7 と同じ). /// フラグを先に立てることで,切断がコールバックの終了を待つ間にコールバック側が UI 呼び出しで /// UI スレッドを待つことがなくなる(ハングの防止).カメラの切断が例外になっても光源は閉じる. /// @@ -228,6 +263,10 @@ /// /// public void ShowImage(Bitmap bmp) { +#if HWTEST + // 実機テスト: フレームの到着数・平均輝度の記録(入力 bmp は読むだけ) + HwTestOnFrame(bmp); +#endif #if MEMMONITOR PreviewProfiler.IncrementCallbackFrames(); PreviewProfiler.SetLastImageSize(bmp.Width, bmp.Height); @@ -352,6 +391,13 @@ return; } #endif +#if HWTEST + // 実機テストでもモーダル表示すると無人実行が止まるため,記録のみ行う(結果ファイルに出す) + if (_hwTestMode) { + HwTestRecordError(msg); + return; + } +#endif MessageBox.Show(msg, APP_NAME, MessageBoxButtons.OK, MessageBoxIcon.Warning); }