diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 7a500cf..2fbc968 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -18,7 +18,7 @@ | ① ピュアロジック層 | ハードウェア・UI・スレッドに依存しない計算/変換ロジック | **NUnit で必ず自動テスト** | | ② 結合ロジック層 | `CameraBase` の校正・撮影・保存の流れ,`Form1` の UI 連携メソッド | **NUnit+偽カメラ+ヘッドレス `Form1` で自動テスト**(後述「結合ロジック層のテスト」) | | ③ SDK アダプタ層 | `IScam`・`Lucam`・`LightSource` | 判断ロジックを純粋関数に切り出して層①で検証する.SDK を呼ぶ部分そのものは層④(後述「SDK アダプタの書き方」) | -| ④ 実機層 | SDK の実呼び出し・実画像の見た目 | 手動テスト(TEST_01).ここに残す項目は最小限にする | +| ④ 実機層 | SDK の実呼び出し・実画像の見た目 | 実機テスト(`--hwtest`.ユーザーの合図があるときだけ)と手動テスト(TEST_01).ここに残す項目は最小限にする | 「テストできない」と「テストしていない」を混同しない.実機が本当に要るのは SDK の実呼び出しと見た目だけであり,それ以外(状態遷移・スレッド連携・失敗時の通知等)は層②・③の手段でテストできる.「UI だから」「スレッドだから」を理由にテストを省かない. @@ -56,6 +56,7 @@ | ビルドテスト | ソリューション全体 | 自動(MSBuild) | 全コミット前 | | 回帰テスト | 色補正・チャート検出の出力(合成チャート,および実機で録画したチャート・白板のフレーム) | 自動(基準データ比較) | リファクタ・修正の前後 | | セルフテスト | 依存ロード・偽カメラでのプレビュー経路・合成チャートでの校正〜保存 | 自動(`--selftest`.`/verify`・`/implement` Phase 1b) | 依存更新・層②〜④の変更後,手動テストの前段 | +| 実機テスト | 層④のうち数値で判定できる SDK の実呼び出し(接続・プレビュー・露光/ゲイン・光源・閉じる・再接続) | 自動(`--hwtest`.Claude が実機を操作する.**ユーザーの合図があるときだけ**) | 層③・④の変更後,手動テストの前段 | | 手動テスト | SDK の実呼び出し・実画像の見た目(層④) | 手動(実機) | 機能追加・修正の完了時 | ※ 実機録画データ(舌診チャート・白板のフレーム)による回帰は PLAN_05 のフェーズ 4 で整備する.**舌の画像は個人情報のためテストデータに含めない**. @@ -95,7 +96,20 @@ | `LightSource` のシリアル通信 | 光源(実機)に依存する.ポートを開けなかったときの扱い等の判断は層③として切り出す | | 画面描画の見た目(`Form1`・`PreviewMonitor` の表示内容) | 目視でしか判断できない.表示用 Bitmap の所有権・更新の流れはセルフテスト(`preview`)で担保する | -※ 層④に手を入れた場合は**設計レビュー+実機回帰**で挙動を確認する.その前段として,実機なしで通せる範囲はセルフテスト(`python scripts/verify/run_selftest.py`.`.claude/verify-profile.md`)で確認できる.`/implement` の Tester フェーズでは,対象が層④だけの場合に限り,テストを書かずに設計レビューで判断してよい.層②・③に当たる部分が含まれるなら,その部分のテストは書く. +層④のうち次の項目は,実機テスト([PLAN_06](../../docs/03_PLAN/PLAN_06_実機自動テスト計画.md).`python scripts/verify/run_hwtest.py --camera `.`HWTEST` フラグ)で Claude が実機を操作して自動で確認できる.**ユーザーが会話の中で合図したときだけ**実行し,合図が無ければ「検証不能(合図なし)」とする(手順・しきい値は `.claude/verify-profile.md` の「実機テスト」節). + +| 項目 | 確認できること | +| --- | --- | +| `connect` | 指定した機種に接続でき,機種名・シリアル番号が取れる | +| `preview` | プレビューが流れる(フレームレートの下限・本モニタへの表示) | +| `exposure-gain` | 露光・ゲインの設定がフレームの明るさに期待どおりの向きで効き,元の値に戻せる | +| `light` | 光源の点灯・消灯が明るさに効き,ポートを閉じた後に開き直せる | +| `close-preview` | プレビュー中に閉じる処理を呼んでもすぐ戻り,後始末の順序が正しい | +| `reconnect` | 切断した後に再接続でき,フレームが届く | + +※ 2026-10-08 時点で実機テストを通したのは ImagingSource だけ(Lumenera は未実施).校正・撮影・保存中や校正中に閉じる等のチャートが要る項目(PLAN_06 フェーズ 2)は未実装のため,引き続き手動テストで確認する. + +※ 層④に手を入れた場合は**設計レビュー+実機回帰**で挙動を確認する.その前段として,実機なしで通せる範囲はセルフテスト(`python scripts/verify/run_selftest.py`.`.claude/verify-profile.md`)で,上表の実機テストの項目はユーザーの合図のもと実機テストで確認できる.`/implement` の Tester フェーズでは,対象が層④だけの場合に限り,テストを書かずに設計レビューで判断してよい.層②・③に当たる部分が含まれるなら,その部分のテストは書く. ## 結合ロジック層のテスト (Integration Logic Tests) diff --git a/.claude/verify-profile.md b/.claude/verify-profile.md index 2f4d3ba..00579ad 100644 --- a/.claude/verify-profile.md +++ b/.claude/verify-profile.md @@ -21,6 +21,7 @@ - ローカルの接続先: このマシンのみ(ビルド・実行ファイル・一時フォルダ).ネットワーク上のサービスは使わない(`--full` 時の `nuget restore` による nuget.org からのパッケージ取得のみ例外) - ローカル以外で許可する環境: なし - 接続を禁止する環境(拒否リスト): 実機カメラ(Lumenera / ImagingSource)・光源(シリアルポート)・本番の撮影データフォルダ(`config.xml` の `File/SaveFolder`,既定 `C:\TIAS_Data`).セルフテストはこれらに触れない設計であり,verifier も触れない +- 条件付きで許可する環境: 実機カメラ・光源・シリアルポートは,**ユーザーが会話の中で実機テストを明示的に合図したとき(オーケストレーターから「実機テストの合図: あり」と渡されたとき)に限り**,後述「実機テスト」節のランナー(`scripts/verify/run_hwtest.py`)経由でだけ接続してよい.本番の撮影データフォルダは実機テストでも触れない(保存先は一時フォルダに差し替わる) ## 実行コマンド (Run Command) @@ -44,6 +45,77 @@ - `FAIL:` 行には例外型・メッセージ・対象アセンブリ名(`[assembly: ...]`)・Fusion ログの要点(`[fusion: ...]`)・スタックの先頭が入る.依存の問題はここで特定できる - 判定は**終了コードと結果ファイル**で行う.`TIASshot.exe` の標準出力は OS のコードページ(cp932)で出るため,直接実行すると文字化けすることがある(ランナーは標準出力を使わない) +## 実機テスト(ユーザーの合図があるときだけ)(Hardware Test) + +[PLAN_06](../docs/03_PLAN/PLAN_06_実機自動テスト計画.md) の実機テスト(`TIASshot.exe --hwtest`,`HWTEST` ビルドフラグ).実際の `Form1` を表示し,指定した機種のカメラと光源を操作して検査する. + +### 実行してよい条件 + +- **ユーザーが会話の中で実機テストを明示的に合図したとき(オーケストレーターから「実機テストの合図: あり」と渡されたとき)だけ**実行する.撮影の本番中に機材を操作しないため +- 合図が無ければ実機テストは実行せず,該当する確認を「**検証不能(合図なし)**」と報告する(セルフテスト等,他の検証は通常どおり行う).合図を推測で補わない.「動作確認して」等の一般的な依頼は合図とみなさない +- 合図は 1 回の検証の依頼ごとに有効とする.以前の会話・別のタスクでの合図を流用しない + +### 実行コマンド + +リポジトリルートで実行する.2 機種とも検査する場合は機種ごとに実行する. + +| 用途 | コマンド | +| --- | --- | +| Lumenera を検査する | `python scripts/verify/run_hwtest.py --camera lucam` | +| ImagingSource を検査する | `python scripts/verify/run_hwtest.py --camera iscam` | + +- `--with-chart`(チャートが必要な検査.PLAN_06 フェーズ 2)は未実装.指定すると機材に触れずに終了コード 2 になるため使わない +- `--no-clean`(Debug 出力を消さない)は開発時の短縮用であり,判定には使わない +- ランナーは Debug|x64 をビルドしてから `TIASshot.exe --hwtest --camera <機種> --hwtest-out <結果ファイル>` を実行ファイルのフォルダで実行する.NUnit テスト・セルフテストは実行しない +- 出力先: `.verify/hwtest/run-<日時>/`(`summary.json`・`build.log`・`hwtest-result.txt`(UTF-8 BOM 付き)) +- ランナーの終了コード: `0`=全項目合格,`1`=不合格あり(ビルド失敗・検査の不合格・タイムアウト),`2`=前提条件を満たさない(→「検証不能」.ツール未導入・TIASshot 起動中・指定機種が見つからない・引数不正・`--with-chart`) +- 実機テスト単体の終了コード(`summary.json` の `hwtest_exit_code`): `0`=全項目合格,`1`=不合格あり,`2`=実機テスト自体の異常(引数不正・指定機種が見つからない・未実装の指定),`3`=タイムアウト(各項目の上限または全体 10 分.ハングの疑い),`null`=ランナーが 720 秒で打ち切った + +### 検査項目 + +| ID | 確認内容 | 測定値と判定(しきい値は ImagingSource の実測(PLAN_06 フェーズ 1)から決めた.結果ファイルの表記は「暫定」のまま) | +| --- | --- | --- | +| `connect` | 指定した機種だけに接続でき,機種名・シリアル番号が取れる | 列挙した台数,`Connect()` の成否と所要時間,機種名・シリアル番号 | +| `preview` | プレビューが流れる | 最初のフレームまでの時間,フレームレート(下限 10 fps),本モニタへの表示,平均輝度と飽和画素の割合 | +| `exposure-gain` | 露光・ゲインの設定がフレームに効く | 先に基準の明るさを探す(元の露光に最も近い 2^n 秒の段に合わせ,平均輝度 30〜220 かつ飽和画素の割合 1% 以下になるまで露光を 1 段(`ExposureLogStep`.時間で 2 倍・半分)ずつ動かす.最大 12 段).露光は基準から 1 段下げ・上げ(比が下げ 0.8 以下・上げ 1.25 以上),ゲインは範囲の幅の ±10%(差が 3 階調以上.片側の向きだけ期待どおりでも合格)で判定.ImagingSource は絶対値(`VCDAbsoluteValueProperty`.秒・dB)で操作する.元の値に戻ったことを読み戻しで確認(相対誤差 1% 以内) | +| `light` | 光源が点灯・消灯し,閉じた後に開き直せる | 照明ボタンでの点灯・消灯の平均輝度の差(100 階調以上),`LightSource.Close` → `Open` の成否,開き直した後の点灯の効き | +| `close-preview` | プレビュー中に閉じる処理を呼んでもすぐ戻る | 閉じる処理(`Form1_FormClosing`)の所要時間(上限 1000 ms)・例外の有無・後始末の順序(closing → timer → camera → light)・閉じた後 1 秒間に届いたフレーム数(2 以下) | +| `reconnect` | 切断した後に再接続できる | 2 回目の `Connect()` の成否・シリアル番号の一致・再接続後のフレームの到着(5 フレーム以上) | +| `cleanup` | 後始末と書き込み禁止の確認 | 露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯はしない),の成否.実行ファイルのフォルダの `*.xml`(`config.xml`・デバイス状態ファイル)の内容が変わっていないこと,本番の保存先の今日のフォルダが変わっていないこと | + +### 判定の読み方 + +- `hwtest-result.txt` はセルフテストと同じ書式(`[PASS] : ...` / `[FAIL] : ...` の見出し行,` - ` 情報・` WARN:` 参考・` FAIL:` 失敗理由の詳細行,最終行 `RESULT: PASS (7/7)` または `RESULT: FAIL (...)`).冒頭に暫定しきい値の一覧(`thresholds (暫定)`)と校正の扱い(`校正: 停止(チャート不要の検査…)`)が出る +- **測定値(フレームレート・平均輝度・所要時間)は PASS / FAIL にかかわらず詳細行に出る**.暫定しきい値で FAIL した場合は,測定値を報告に必ず書く(しきい値の決め直しに使う) +- タイムアウト時は,止まった項目の見出しの下に途中までの測定値と `FAIL: タイムアウト: ...(実行中のステップ: ...)` が出る.どの SDK 呼び出しで止まったかはステップ名で特定する +- 指定機種が見つからない場合は `RESULT: FAIL (camera not found: <機種>)`(終了コード 2).機材の接続を人間に依頼する + +### 前提条件 + +| 前提条件 | 確認方法 | 満たされない場合 | +| --- | --- | --- | +| ユーザーの合図がある | オーケストレーターからの指示に「実機テストの合図: あり」がある | 実行せず「検証不能(合図なし)」と報告する | +| 撮影の本番中でない・TIASshot が起動していない | ランナーが `tasklist` で確認する(起動中なら終了コード 2) | 検証不能とし,人間に TIASshot の終了を依頼する(**自分でプロセスを止めない**) | +| 指定機種のカメラと光源が開発 PC に接続されている | `connect` の詳細行(列挙した台数)・`light` の詳細行(ポートの有無) | カメラが無ければ検証不能(終了コード 2).光源が無ければ `light` が FAIL になる.機材の接続を人間に依頼する | +| カメラの前の穴に**カラーチャート(または白板)がはめてある**(穴が空だと暗く,明るさの変化を評価しにくい).チャート不要の検査では校正は止まる(結果ファイル冒頭に `校正: 停止(チャート不要の検査…)`) | 人間に確認する.`preview`・`light` の詳細行 `校正は止まっています` | はめていなければ人間にはめてもらってから再実行する.`preview`・`light` が `FAIL: 校正を止めているはずなのに校正が…` なら校正の停止が効いていない不具合として報告する(露光・ゲイン・カメラ本体のホワイトバランスが乱れている可能性あり) | +| セルフテストの前提(Visual Studio / Build Tools・`packages/`・IC Imaging Control 3.5) | 上記「前提条件と確認方法」と同じ | 同上 | + +### 既知のノイズ + +- 平均輝度・フレームレートは照明・被写体・カメラの個体差で変わる.判定は変化の向きと量で行い,絶対値は判定しない +- 実測(ImagingSource DFK 33UX178・カラーチャートをはめた状態): フレームレート 13〜31 fps(実行ごとのばらつきが大きい),光源の点灯と消灯の差 149〜196,閉じる処理 75〜221 ms.これより大きく外れたら,しきい値より先に照明・チャート・機材の状態を疑う +- 飽和は「254 以上の画素の割合が 1% を超える」で判定する(平均輝度では判定しない).上げた向きの結果が飽和した場合,または真っ暗(平均輝度 5 以下)の場合,その向きの確認は省かれる(詳細行に「確認しない」と出る).両方とも省かれると FAIL になる.基準探しで中間の明るさにできなかった場合も FAIL になる +- 露光・ゲインが設定範囲の端にあると,その向きの設定が変わらず確認が省かれる(詳細行に「反映されませんでした」と出る) +- `exposure-gain` の `WARN: 元に戻した後の平均輝度が最初と離れています` は参考(判定は設定値の読み戻しで行う) +- 実際のフォームが表示される(副ディスプレイがあればプレビューモニタも開く).メモリ観測ウィンドウ(`MEMMONITOR`)は開かない + +### 後始末 + +- 実機テストは終了時(失敗時も)に「変えた露光・ゲインを元に戻す → カメラ切断 → 光源のポートを閉じる」を行い,`cleanup` 項目に結果が出る.光源は消灯しない(アプリの終了時と同じく点いたまま残るのが仕様.上流 v1.7 と同じ).`light` 項目は点灯・消灯・再点灯を検査し,最後は点灯した状態で終わる +- タイムアウト・未処理例外の場合は光源のポートだけを緊急に閉じる(消灯はしない.カメラには触れない.露光・ゲインは次の接続で設定ファイルの値に戻る).`WARN: 緊急の後始末: 光源のポートのクローズが 5 秒以内に終わりません` が出たら,ポートが開いたままの可能性を人間に報告する(光源が点いたままなのは仕様であり報告不要.`light` 項目の途中(消灯中)で止まった場合は消灯したまま残りうる) +- 書き込み先は `%TEMP%\TIASshot-hwtest\work-<日時>-\`(一時設定ファイル `config.hwtest.xml`.チャート不要の検査では校正を止めるため校正データは書かれない)と `.verify/hwtest/run-<日時>/` だけ.合格時と前提を満たさずに終わった場合(終了コード 2)は `work-*` を自動削除し(`--hwtest-keep` 指定時は残す),不合格(1)・タイムアウト(3)時は残して結果ファイルの末尾に `work dir kept: <パス>` と出る.残った `work-*` の有無とパスを報告の「後始末」に書く +- 同梱の `config.xml`・デバイス状態ファイル(`{DeviceName}.xml`)・本番の保存先には書き込まない(`cleanup` で確認する) + ## 前提条件と確認方法 (Preconditions) | 前提条件 | 確認方法 | 満たされない場合 | @@ -81,6 +153,7 @@ ## 禁止操作 (Prohibited Operations) - `--selftest` を付けない `TIASshot.exe` の起動(UI が開き,実機カメラ・光源(シリアルポート)へ接続を試みる) + - 例外: ユーザーの合図があるときの `python scripts/verify/run_hwtest.py --camera `(上記「実機テスト」節).`TIASshot.exe --hwtest` の直接実行・合図なしの実行・`Release` の exe での実行はしない - `config.xml`(リポジトリ内・bin 内とも)や本番の保存先フォルダ(`C:\TIAS_Data` 等)の編集・削除 - 自分が起動していないプロセスの停止(人間が起動した TIASshot・Visual Studio のデバッグ実行を止めない) - `packages.bak-*` 以外のリポジトリ内ディレクトリの手動削除・改名(`packages` の退避と復元はランナーの `--full` だけが行う) diff --git a/CLAUDE.md b/CLAUDE.md index bda2329..4ddecaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: Claude が実機(カメラ・光源)を操作して層④を確認する実機自動テストの計画(PLAN_06)を策定した +最新: 実機自動テスト(PLAN_06 フェーズ 1)を実装し,ImagingSource の実機で 7 項目が合格した(Lumenera は未実施) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/TIASshot.Tests/Form1CloseIntegrationTests.cs b/TIASshot.Tests/Form1CloseIntegrationTests.cs index 35603f0..e70aab6 100644 --- a/TIASshot.Tests/Form1CloseIntegrationTests.cs +++ b/TIASshot.Tests/Form1CloseIntegrationTests.cs @@ -15,7 +15,7 @@ /// 仕様: PLAN_05「束 B の方針」(アプリを閉じるとき)・testing.md「層②: CameraBase の切断・終了」 /// - 保存中なら閉じるのを取り消して「保存中」と知らせる(未保存の画像を失わない) /// - 閉じ始めたらカメラのコールバックから UI への呼び出しを行わない(切断がコールバックの終了を待つ間のハング防止) - /// - 閉じるときは「閉じ始め → 再描画タイマー停止 → カメラ切断 → 光源を消してポートを閉じる」の順に後始末する + /// - 閉じるときは「閉じ始め → 再描画タイマー停止 → カメラ切断 → 光源のポートを閉じる(消灯はしない)」の順に後始末する /// - 光源が開いていない(ヘッドレスでは実機が無い)場合も例外にならない /// /// 観測は Form1.SelfTest.cs のアクセサ(SelfTestRequestClose・SelfTestIsClosing・SelfTestCloseSteps・ diff --git a/TIASshot.Tests/HwTestJudgeTests.cs b/TIASshot.Tests/HwTestJudgeTests.cs new file mode 100644 index 0000000..2afcf15 --- /dev/null +++ b/TIASshot.Tests/HwTestJudgeTests.cs @@ -0,0 +1,1067 @@ +#if HWTEST +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// 実機テストの判断ロジック(HwTestJudge)のテスト(層①.SDK・UI に依存しない純粋関数). + /// + /// 仕様: PLAN_06「検査項目」「守ること」・.claude/verify-profile.md「実機テスト」・HwTestJudge の各関数の契約 + /// - preview: フレームレートの下限.close-preview: 所要時間の上限.ウォッチドッグは全体の上限を優先 + /// - exposure-gain: 露光は Linear なら ×0.5/×2,Log2Step なら最も近い 2^n から ±1 段(範囲内に収める). + /// ゲインは範囲の幅の ±10%.露光は比(1 段下げ ≦ 0.8・上げ ≧ 1.25,確認した向きはすべて期待どおり), + /// ゲインは差(≧ 3,片側だけでも合格).動かした側が飽和(飽和画素の割合 > 1%)していれば,その向きは確認しない. + /// 両方とも確認できなければ FAIL + /// - 基準探しの判断の順番: 範囲の端 → 測れない → 中間に入った → 飛び越えた → 段数の上限 → 続ける + /// - light: 点灯と消灯の差 ≧ 100.復元: 読み戻しの相対誤差 1%(ゲインは範囲の幅 × 0.1% との大きい方) + /// - チャート不要の検査では校正を止める.終了コード 2/1/0,作業フォルダは keep 指定または 1・3 のとき残す + /// 実測の例は .verify/hwtest/run-20261008-133812/hwtest-result.txt(DFK 33UX178)から採った. + /// 本クラスは HWTEST 構成(Debug|x64)でのみコンパイルされる. + /// + [TestFixture] + public class HwTestJudgeTests { + + private const double Eps = 1e-9; + + private static HwTestMeasurement M(double mean, double saturatedFraction) { + return new HwTestMeasurement { Mean = mean, SaturatedFraction = saturatedFraction, MeanSaturatedFraction = saturatedFraction, Count = 8 }; + } + + private static double NextExp(double current, double min, double max, HwTestValueScale scale, int direction) { + return HwTestJudge.NextExposure(current, min, max, scale, direction, + HwTestThresholds.ExposureLogStep, HwTestThresholds.ExposureDownFactor, HwTestThresholds.ExposureUpFactor); + } + + private static HwTestBaselineSearchDecision Decide(double mean, double sat, int direction, bool changed, int stepsDone) { + return HwTestJudge.DecideBaselineSearch(mean, sat, direction, changed, stepsDone, 12, 30, 220, 0.01); + } + + // ================================================================ + // フレームレート・しきい値 + // ================================================================ + + [Test] + public void FrameRate_フレーム数を秒で割る() { + Assert.That(HwTestJudge.FrameRate(42, TimeSpan.FromSeconds(3)), Is.EqualTo(14.0).Within(Eps)); + } + + [Test] + public void FrameRate_時間が0_0() { + Assert.That(HwTestJudge.FrameRate(42, TimeSpan.Zero), Is.EqualTo(0.0)); + } + + [Test] + public void FrameRate_時間が負_0() { + Assert.That(HwTestJudge.FrameRate(42, TimeSpan.FromSeconds(-1)), Is.EqualTo(0.0)); + } + + [Test] + public void FrameRate_フレーム0_0() { + Assert.That(HwTestJudge.FrameRate(0, TimeSpan.FromSeconds(3)), Is.EqualTo(0.0)); + } + + [Test] + public void MeetsMinFrameRate_下限ちょうど_合格() { + Assert.That(HwTestJudge.MeetsMinFrameRate(10.0, 10.0), Is.True); + } + + [Test] + public void MeetsMinFrameRate_下限未満_不合格() { + Assert.That(HwTestJudge.MeetsMinFrameRate(9.99, 10.0), Is.False); + } + + [Test] + public void MeetsMinFrameRate_NaN_不合格() { + Assert.That(HwTestJudge.MeetsMinFrameRate(double.NaN, 10.0), Is.False); + } + + [Test] + public void MeetsMinFrameRate_実測13_28fps_下限10で合格() { + // 実測: 13.28 fps(42 フレーム / 3.16 秒) + Assert.That(HwTestJudge.MeetsMinFrameRate(HwTestJudge.FrameRate(42, TimeSpan.FromSeconds(3.16)), HwTestThresholds.MinPreviewFps), Is.True); + } + + [Test] + public void WithinLimit_上限ちょうど_以内() { + Assert.That(HwTestJudge.WithinLimit(TimeSpan.FromMilliseconds(1000), TimeSpan.FromMilliseconds(1000)), Is.True); + } + + [Test] + public void WithinLimit_上限超え_超過() { + Assert.That(HwTestJudge.WithinLimit(TimeSpan.FromMilliseconds(1001), TimeSpan.FromMilliseconds(1000)), Is.False); + } + + [Test] + public void FindOverdue_上限内_None() { + Assert.That(HwTestJudge.FindOverdue(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(10), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(60)), + Is.EqualTo(HwTestOverdue.None)); + } + + [Test] + public void FindOverdue_上限ちょうど_None() { + Assert.That(HwTestJudge.FindOverdue(TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10), TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)), + Is.EqualTo(HwTestOverdue.None)); + } + + [Test] + public void FindOverdue_項目の上限超え_Item() { + Assert.That(HwTestJudge.FindOverdue(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(10), TimeSpan.FromSeconds(61), TimeSpan.FromSeconds(60)), + Is.EqualTo(HwTestOverdue.Item)); + } + + [Test] + public void FindOverdue_全体と項目の両方超え_全体を優先() { + Assert.That(HwTestJudge.FindOverdue(TimeSpan.FromMinutes(11), TimeSpan.FromMinutes(10), TimeSpan.FromSeconds(61), TimeSpan.FromSeconds(60)), + Is.EqualTo(HwTestOverdue.Overall)); + } + + [Test] + public void FindOverdue_実行中の項目なし_項目の上限を見ない() { + Assert.That(HwTestJudge.FindOverdue(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(10), TimeSpan.FromHours(1), null), + Is.EqualTo(HwTestOverdue.None)); + } + + // ================================================================ + // 露光・ゲインの段の計算 + // ================================================================ + + [Test] + public void NextExposure_Linear_下げる_半分() { + Assert.That(NextExp(0.01, 0.0001, 1, HwTestValueScale.Linear, -1), Is.EqualTo(0.005).Within(Eps)); + } + + [Test] + public void NextExposure_Linear_上げる_2倍() { + Assert.That(NextExp(0.01, 0.0001, 1, HwTestValueScale.Linear, +1), Is.EqualTo(0.02).Within(Eps)); + } + + [Test] + public void NextExposure_Linear_上げて最大を超える_最大に収める() { + Assert.That(NextExp(0.8, 0.0001, 1, HwTestValueScale.Linear, +1), Is.EqualTo(1.0)); + } + + [Test] + public void NextExposure_Linear_下げて最小を下回る_最小に収める() { + Assert.That(NextExp(0.0003, 0.0002, 1, HwTestValueScale.Linear, -1), Is.EqualTo(0.0002)); + } + + [Test] + public void NextExposure_Log2Step_下げる_最も近い2のn乗から1段下() { + // 実測: 露光 0.016667(最も近い 2^n は 2^-6 = 0.015625)→ 下げた 0.007813(2^-7) + Assert.That(NextExp(0.016667, 2e-5, 60, HwTestValueScale.Log2Step, -1), Is.EqualTo(Math.Pow(2, -7)).Within(1e-12)); + } + + [Test] + public void NextExposure_Log2Step_上げる_最も近い2のn乗から1段上() { + // 実測: 露光 0.016667 → 上げた 0.03125(2^-5) + Assert.That(NextExp(0.016667, 2e-5, 60, HwTestValueScale.Log2Step, +1), Is.EqualTo(Math.Pow(2, -5)).Within(1e-12)); + } + + [Test] + public void NextExposure_Log2Step_ちょうど2のn乗_そこから1段() { + Assert.That(NextExp(0.015625, 2e-5, 60, HwTestValueScale.Log2Step, +1), Is.EqualTo(0.03125).Within(1e-12)); + } + + [Test] + public void NextExposure_Log2Step_最大を超える_最大に収める() { + Assert.That(NextExp(0.5, 2e-5, 0.6, HwTestValueScale.Log2Step, +1), Is.EqualTo(0.6)); + } + + [Test] + public void NextExposure_Log2Step_最小を下回る_最小に収める() { + Assert.That(NextExp(3e-5, 2e-5, 60, HwTestValueScale.Log2Step, -1), Is.EqualTo(2e-5)); + } + + [Test] + public void NextExposure_向きが0_ArgumentException() { + Assert.Throws((Action)(() => NextExp(0.01, 0.0001, 1, HwTestValueScale.Linear, 0))); + } + + [Test] + public void NextExposure_最小が最大より大きい_ArgumentException() { + Assert.Throws((Action)(() => NextExp(0.01, 1, 0.0001, HwTestValueScale.Linear, 1))); + } + + [Test] + public void NextExposure_現在値がNaN_ArgumentException() { + Assert.Throws((Action)(() => NextExp(double.NaN, 0.0001, 1, HwTestValueScale.Linear, 1))); + } + + [Test] + public void NextExposure_範囲が無限大_ArgumentException() { + Assert.Throws((Action)(() => NextExp(0.01, 0, double.PositiveInfinity, HwTestValueScale.Linear, 1))); + } + + [Test] + public void NextExposure_Log2Stepで現在値が0_ArgumentException() { + Assert.Throws((Action)(() => NextExp(0, 0, 1, HwTestValueScale.Log2Step, 1))); + } + + [Test] + public void NextExposure_Log2Stepで段の量が0_ArgumentException() { + Assert.Throws((Action)(() => + HwTestJudge.NextExposure(0.01, 0, 1, HwTestValueScale.Log2Step, 1, 0, 0.5, 2))); + } + + [Test] + public void NextExposure_未知の種類_ArgumentException() { + Assert.Throws((Action)(() => NextExp(0.01, 0, 1, (HwTestValueScale)99, 1))); + } + + [Test] + public void AlignExposure_Linear_そのまま() { + Assert.That(HwTestJudge.AlignExposure(0.016667, 2e-5, 60, HwTestValueScale.Linear), Is.EqualTo(0.016667)); + } + + [Test] + public void AlignExposure_Log2Step_最も近い2のn乗に合わせる() { + // 実測: 元の 0.016667 に最も近い 2^n(0.015625)に合わせた + Assert.That(HwTestJudge.AlignExposure(0.016667, 2e-5, 60, HwTestValueScale.Log2Step), Is.EqualTo(0.015625).Within(1e-12)); + } + + [Test] + public void AlignExposure_Log2Step_最も近い2のn乗が最小未満_範囲内で最も近い2のn乗() { + // 0.02 に最も近いのは 2^-6(0.015625)だが最小 0.02 未満.範囲 [0.02, 1] の中で最も近いのは 2^-5 + Assert.That(HwTestJudge.AlignExposure(0.02, 0.02, 1, HwTestValueScale.Log2Step), Is.EqualTo(0.03125).Within(1e-12)); + } + + [Test] + public void AlignExposure_Log2Step_最も近い2のn乗が最大超え_範囲内で最も近い2のn乗() { + // 2.9 に最も近いのは 4(2^2)だが最大 2.9 を超える.範囲内で最も近いのは 2(2^1) + Assert.That(HwTestJudge.AlignExposure(2.9, 0.1, 2.9, HwTestValueScale.Log2Step), Is.EqualTo(2.0).Within(1e-12)); + } + + [Test] + public void AlignExposure_Log2Step_最大がちょうど2のn乗_最大に合わせる() { + // 7 に最も近いのは 8 = 最大(log の丸め誤差で 1 段ずれないこと) + Assert.That(HwTestJudge.AlignExposure(7, 0.1, 8, HwTestValueScale.Log2Step), Is.EqualTo(8.0).Within(1e-12)); + } + + [Test] + public void AlignExposure_Log2Step_範囲内に2のn乗が無い_そのまま() { + Assert.That(HwTestJudge.AlignExposure(0.35, 0.3, 0.4, HwTestValueScale.Log2Step), Is.EqualTo(0.35)); + } + + [Test] + public void AlignExposure_Log2Stepで現在値が0_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.AlignExposure(0, 0, 1, HwTestValueScale.Log2Step))); + } + + [Test] + public void AlignExposure_範囲が不正_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.AlignExposure(0.5, 1, 0, HwTestValueScale.Linear))); + } + + [Test] + public void ScaleSetting_倍率を掛ける() { + Assert.That(HwTestJudge.ScaleSetting(0.4, 0, 1, 0.5), Is.EqualTo(0.2).Within(Eps)); + } + + [Test] + public void ScaleSetting_範囲外_範囲内に収める() { + Assert.That(HwTestJudge.ScaleSetting(0.6, 0, 1, 2), Is.EqualTo(1.0)); + } + + [Test] + public void ScaleSetting_現在値0で上げる_最小から範囲の幅の5パーセント() { + Assert.That(HwTestJudge.ScaleSetting(0, 0, 10, 2), Is.EqualTo(0.5).Within(Eps)); + } + + [Test] + public void ScaleSetting_現在値0で下げる_変わらない() { + Assert.That(HwTestJudge.ScaleSetting(0, 0, 10, 0.5), Is.EqualTo(0.0)); + } + + [Test] + public void ScaleSetting_倍率0_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.ScaleSetting(0.5, 0, 1, 0))); + } + + [Test] + public void ScaleSetting_倍率NaN_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.ScaleSetting(0.5, 0, 1, double.NaN))); + } + + [Test] + public void StepSetting_範囲の幅の10パーセント上げる() { + // 実測: ゲイン 1(範囲 0〜48)→ 上げた 5.8 + Assert.That(HwTestJudge.StepSetting(1, 0, 48, HwTestThresholds.GainStepFraction), Is.EqualTo(5.8).Within(Eps)); + } + + [Test] + public void StepSetting_下げて最小を下回る_最小に収める() { + // 実測: ゲイン 1(範囲 0〜48)→ 下げた 0 + Assert.That(HwTestJudge.StepSetting(1, 0, 48, -HwTestThresholds.GainStepFraction), Is.EqualTo(0.0)); + } + + [Test] + public void StepSetting_上げて最大を超える_最大に収める() { + Assert.That(HwTestJudge.StepSetting(47, 0, 48, HwTestThresholds.GainStepFraction), Is.EqualTo(48.0)); + } + + [Test] + public void StepSetting_割合NaN_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.StepSetting(1, 0, 48, double.NaN))); + } + + [Test] + public void StepSetting_範囲が不正_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.StepSetting(1, 48, 0, 0.1))); + } + + // ================================================================ + // 輝度・飽和 + // ================================================================ + + [Test] + public void FrameStats_BGR_3チャネルの平均() { + var rows = new byte[] { 10, 20, 30, 40, 50, 60 }; + var s = HwTestJudge.FrameStats(rows, 1, 2, 6, 3, 1, 254); + Assert.That(s.Mean, Is.EqualTo(35.0).Within(Eps)); + } + + [Test] + public void FrameStats_飽和なし_割合0() { + var rows = new byte[] { 253, 253, 253, 0, 0, 0 }; + var s = HwTestJudge.FrameStats(rows, 1, 2, 6, 3, 1, 254); + Assert.That(s.SaturatedFraction, Is.EqualTo(0.0)); + } + + [Test] + public void FrameStats_いずれかのチャネルが254以上_飽和画素として数える() { + // 2 画素のうち 1 画素の R だけが 254 + var rows = new byte[] { 0, 0, 254, 100, 100, 100 }; + var s = HwTestJudge.FrameStats(rows, 1, 2, 6, 3, 1, HwTestThresholds.SaturatedPixelLevel); + Assert.That(s.SaturatedFraction, Is.EqualTo(0.5).Within(Eps)); + } + + [Test] + public void FrameStats_4チャネル_アルファを読まない() { + var rows = new byte[] { 0, 0, 0, 255, 30, 30, 30, 255 }; + var s = HwTestJudge.FrameStats(rows, 1, 2, 8, 4, 1, 254); + Assert.That(new[] { s.Mean, s.SaturatedFraction }, Is.EqualTo(new[] { 15.0, 0.0 }).Within(Eps)); + } + + [Test] + public void FrameStats_1チャネル_先頭チャネルの平均() { + var rows = new byte[] { 10, 30, 50, 70 }; + var s = HwTestJudge.FrameStats(rows, 2, 2, 2, 1, 1, 254); + Assert.That(s.Mean, Is.EqualTo(40.0).Within(Eps)); + } + + [Test] + public void FrameStats_間引き_指定画素ごとに読む() { + // 1 チャネル・4 画素で 2 画素ごと → 画素 0 と 2 だけを読む + var rows = new byte[] { 10, 200, 30, 200 }; + var s = HwTestJudge.FrameStats(rows, 1, 4, 4, 1, 2, 254); + Assert.That(s.Mean, Is.EqualTo(20.0).Within(Eps)); + } + + [Test] + public void FrameStats_行の詰め物_読まない() { + // 1 行 = 2 画素 × 3 バイト + 詰め物 2 バイト(255) + var rows = new byte[] { 10, 10, 10, 20, 20, 20, 255, 255, 30, 30, 30, 40, 40, 40, 255, 255 }; + var s = HwTestJudge.FrameStats(rows, 2, 2, 8, 3, 1, 254); + Assert.That(new[] { s.Mean, s.SaturatedFraction }, Is.EqualTo(new[] { 25.0, 0.0 }).Within(Eps)); + } + + [Test] + public void FrameStats_rowsがnull_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.FrameStats(null, 1, 1, 3, 3, 1, 254))); + } + + [Test] + public void FrameStats_1画素のバイト数が5_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.FrameStats(new byte[10], 1, 2, 10, 5, 1, 254))); + } + + [Test] + public void FrameStats_1行のバイト数が不足_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.FrameStats(new byte[6], 1, 2, 5, 3, 1, 254))); + } + + [Test] + public void FrameStats_画素データが不足_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.FrameStats(new byte[6], 2, 2, 6, 3, 1, 254))); + } + + [Test] + public void FrameStats_間引き0_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.FrameStats(new byte[6], 1, 2, 6, 3, 0, 254))); + } + + [Test] + public void FrameStats_画素なし_ArgumentException() { + Assert.Throws((Action)(() => HwTestJudge.FrameStats(new byte[6], 0, 2, 6, 3, 1, 254))); + } + + [Test] + public void IsSaturated_しきい値ちょうど_飽和でない() { + Assert.That(HwTestJudge.IsSaturated(0.01, 0.01), Is.False); + } + + [Test] + public void IsSaturated_しきい値超え_飽和() { + Assert.That(HwTestJudge.IsSaturated(0.0101, 0.01), Is.True); + } + + [Test] + public void IsSaturated_NaN_飽和でない() { + Assert.That(HwTestJudge.IsSaturated(double.NaN, 0.01), Is.False); + } + + [TestCase(30.0)] + [TestCase(125.0)] + [TestCase(220.0)] + public void IsMidBrightness_範囲内と境界_true(double mean) { + Assert.That(HwTestJudge.IsMidBrightness(mean, 30, 220), Is.True); + } + + [TestCase(29.99)] + [TestCase(220.01)] + [TestCase(double.NaN)] + public void IsMidBrightness_範囲外とNaN_false(double mean) { + Assert.That(HwTestJudge.IsMidBrightness(mean, 30, 220), Is.False); + } + + [Test] + public void IsBaselineCandidate_中間で飽和なし_true() { + // 実測: 露光 0.015625 の平均輝度 158.94・飽和画素の割合 0.00% は基準にできる + Assert.That(HwTestJudge.IsBaselineCandidate(158.94, 0.0, 30, 220, 0.01), Is.True); + } + + [Test] + public void IsBaselineCandidate_中間でも飽和_false() { + Assert.That(HwTestJudge.IsBaselineCandidate(200, 0.05, 30, 220, 0.01), Is.False); + } + + [Test] + public void IsBaselineCandidate_飽和割合NaN_false() { + Assert.That(HwTestJudge.IsBaselineCandidate(150, double.NaN, 30, 220, 0.01), Is.False); + } + + [Test] + public void IsBaselineCandidate_明るすぎ_false() { + Assert.That(HwTestJudge.IsBaselineCandidate(230, 0.0, 30, 220, 0.01), Is.False); + } + + [Test] + public void Summarize_平均輝度は平均() { + var m = HwTestJudge.Summarize(new[] { new HwTestFrameStats(100, 0.0), new HwTestFrameStats(110, 0.04) }); + Assert.That(m.Mean, Is.EqualTo(105.0).Within(Eps)); + } + + [Test] + public void Summarize_飽和割合は最大() { + var m = HwTestJudge.Summarize(new[] { new HwTestFrameStats(100, 0.0), new HwTestFrameStats(110, 0.04), new HwTestFrameStats(100, 0.02) }); + Assert.That(m.SaturatedFraction, Is.EqualTo(0.04).Within(Eps)); + } + + [Test] + public void Summarize_飽和割合の平均を参考に持つ() { + var m = HwTestJudge.Summarize(new[] { new HwTestFrameStats(100, 0.0), new HwTestFrameStats(110, 0.04) }); + Assert.That(m.MeanSaturatedFraction, Is.EqualTo(0.02).Within(Eps)); + } + + [Test] + public void Summarize_フレーム数() { + var m = HwTestJudge.Summarize(new[] { new HwTestFrameStats(100, 0.0), new HwTestFrameStats(110, 0.04) }); + Assert.That(m.Count, Is.EqualTo(2)); + } + + [Test] + public void Summarize_空_測定できない() { + var m = HwTestJudge.Summarize(new HwTestFrameStats[0]); + Assert.That(new object[] { double.IsNaN(m.Mean), double.IsNaN(m.SaturatedFraction), m.Count }, Is.EqualTo(new object[] { true, true, 0 })); + } + + [Test] + public void Summarize_null_測定できない() { + var m = HwTestJudge.Summarize(null); + Assert.That(new object[] { double.IsNaN(m.Mean), m.Count }, Is.EqualTo(new object[] { true, 0 })); + } + + // ================================================================ + // 基準探し + // ================================================================ + + [Test] + public void BaselineSearchDirection_明るすぎ_下げる() { + Assert.That(HwTestJudge.BaselineSearchDirection(230, 0.0, 30, 220, 0.01), Is.EqualTo(-1)); + } + + [Test] + public void BaselineSearchDirection_中間でも飽和_下げる() { + Assert.That(HwTestJudge.BaselineSearchDirection(200, 0.05, 30, 220, 0.01), Is.EqualTo(-1)); + } + + [Test] + public void BaselineSearchDirection_暗すぎ_上げる() { + Assert.That(HwTestJudge.BaselineSearchDirection(20, 0.0, 30, 220, 0.01), Is.EqualTo(1)); + } + + [Test] + public void BaselineSearchDirection_基準にできる明るさ_動かさない() { + Assert.That(HwTestJudge.BaselineSearchDirection(158.94, 0.0, 30, 220, 0.01), Is.EqualTo(0)); + } + + [Test] + public void BaselineSearchDirection_測定できない_動かさない() { + Assert.That(HwTestJudge.BaselineSearchDirection(double.NaN, double.NaN, 30, 220, 0.01), Is.EqualTo(0)); + } + + [Test] + public void DecideBaselineSearch_設定が変わらない_範囲の端を最優先() { + // 測定できない・中間に入った,のどちらにも当たる値でも範囲の端が先 + Assert.That(Decide(double.NaN, double.NaN, -1, false, 1), Is.EqualTo(HwTestBaselineSearchDecision.StopRangeEnd)); + } + + [Test] + public void DecideBaselineSearch_設定が変わらない_中間でも範囲の端() { + Assert.That(Decide(150, 0.0, -1, false, 1), Is.EqualTo(HwTestBaselineSearchDecision.StopRangeEnd)); + } + + [Test] + public void DecideBaselineSearch_平均輝度NaN_測れない() { + Assert.That(Decide(double.NaN, 0.0, -1, true, 1), Is.EqualTo(HwTestBaselineSearchDecision.StopNotMeasured)); + } + + [Test] + public void DecideBaselineSearch_飽和割合NaN_測れない() { + Assert.That(Decide(150, double.NaN, -1, true, 1), Is.EqualTo(HwTestBaselineSearchDecision.StopNotMeasured)); + } + + [Test] + public void DecideBaselineSearch_中間に入った_Found() { + Assert.That(Decide(150, 0.0, -1, true, 1), Is.EqualTo(HwTestBaselineSearchDecision.Found)); + } + + [Test] + public void DecideBaselineSearch_段数の上限で中間に入った_上限よりFoundが先() { + Assert.That(Decide(150, 0.0, -1, true, 12), Is.EqualTo(HwTestBaselineSearchDecision.Found)); + } + + [Test] + public void DecideBaselineSearch_下げる向きで暗すぎになった_飛び越えた() { + Assert.That(Decide(20, 0.0, -1, true, 1), Is.EqualTo(HwTestBaselineSearchDecision.StopOvershoot)); + } + + [Test] + public void DecideBaselineSearch_上げる向きで明るすぎになった_飛び越えた() { + Assert.That(Decide(230, 0.0, 1, true, 1), Is.EqualTo(HwTestBaselineSearchDecision.StopOvershoot)); + } + + [Test] + public void DecideBaselineSearch_上げる向きで飽和した_飛び越えた() { + Assert.That(Decide(200, 0.05, 1, true, 1), Is.EqualTo(HwTestBaselineSearchDecision.StopOvershoot)); + } + + [Test] + public void DecideBaselineSearch_段数の上限で飛び越えた_上限より飛び越えたが先() { + Assert.That(Decide(20, 0.0, -1, true, 12), Is.EqualTo(HwTestBaselineSearchDecision.StopOvershoot)); + } + + [Test] + public void DecideBaselineSearch_段数の上限でまだ明るすぎ_上限() { + Assert.That(Decide(230, 0.0, -1, true, 12), Is.EqualTo(HwTestBaselineSearchDecision.StopMaxSteps)); + } + + [Test] + public void DecideBaselineSearch_まだ明るすぎ_続ける() { + Assert.That(Decide(230, 0.0, -1, true, 3), Is.EqualTo(HwTestBaselineSearchDecision.Continue)); + } + + [Test] + public void DecideBaselineSearch_まだ暗すぎ_続ける() { + Assert.That(Decide(20, 0.0, 1, true, 3), Is.EqualTo(HwTestBaselineSearchDecision.Continue)); + } + + [Test] + public void ChooseBaselineIndex_中間に最も近い段() { + var samples = new List { + new HwTestBaselineSample(1, 250, 0), new HwTestBaselineSample(2, 228, 0), new HwTestBaselineSample(3, 10, 0), + }; + Assert.That(HwTestJudge.ChooseBaselineIndex(samples, 30, 220, 0.01), Is.EqualTo(1)); + } + + [Test] + public void ChooseBaselineIndex_飽和していない段を優先() { + // 段 0 は中間の範囲内だが飽和,段 1 は暗すぎるが飽和していない + var samples = new List { + new HwTestBaselineSample(1, 200, 0.05), new HwTestBaselineSample(2, 5, 0), + }; + Assert.That(HwTestJudge.ChooseBaselineIndex(samples, 30, 220, 0.01), Is.EqualTo(1)); + } + + [Test] + public void ChooseBaselineIndex_すべて飽和_その中で中間に最も近い段() { + var samples = new List { + new HwTestBaselineSample(1, 250, 0.5), new HwTestBaselineSample(2, 230, 0.2), + }; + Assert.That(HwTestJudge.ChooseBaselineIndex(samples, 30, 220, 0.01), Is.EqualTo(1)); + } + + [Test] + public void ChooseBaselineIndex_NaNは除く() { + var samples = new List { + new HwTestBaselineSample(1, double.NaN, double.NaN), new HwTestBaselineSample(2, 10, 0), + }; + Assert.That(HwTestJudge.ChooseBaselineIndex(samples, 30, 220, 0.01), Is.EqualTo(1)); + } + + [Test] + public void ChooseBaselineIndex_同じ近さ_先の段() { + var samples = new List { + new HwTestBaselineSample(1, 240, 0), new HwTestBaselineSample(2, 10, 0), + }; + Assert.That(HwTestJudge.ChooseBaselineIndex(samples, 30, 220, 0.01), Is.EqualTo(0)); + } + + [Test] + public void ChooseBaselineIndex_すべてNaN_マイナス1() { + var samples = new List { new HwTestBaselineSample(1, double.NaN, double.NaN) }; + Assert.That(HwTestJudge.ChooseBaselineIndex(samples, 30, 220, 0.01), Is.EqualTo(-1)); + } + + [Test] + public void ChooseBaselineIndex_空_マイナス1() { + Assert.That(HwTestJudge.ChooseBaselineIndex(new List(), 30, 220, 0.01), Is.EqualTo(-1)); + } + + [Test] + public void ChooseBaselineIndex_null_マイナス1() { + Assert.That(HwTestJudge.ChooseBaselineIndex(null, 30, 220, 0.01), Is.EqualTo(-1)); + } + + // ================================================================ + // 方向の判定(露光: 比・すべて確認した向きが期待どおり) + // ================================================================ + + [Test] + public void ExposureCriteria_比で見て下げる0_8以下_上げる1_25以上_すべて確認() { + var c = HwTestJudge.ExposureCriteria(); + Assert.That(new object[] { c.UseRatio, c.LowerLimit, c.RaiseLimit, c.MaxSaturatedFraction, c.RequireAllChecked }, + Is.EqualTo(new object[] { true, 0.8, 1.25, 0.01, true })); + } + + [Test] + public void JudgeDirection_露光_実測_下げる側が期待どおりで合格() { + // 実測: 基準 158.92 → 下げた 86.63(比 0.545).上げた 210.14 は飽和割合 70.18% + var r = HwTestJudge.JudgeDirection(M(158.92, 0.0), M(86.63, 0.0), M(210.14, 0.7018), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Pass, Is.True); + } + + [Test] + public void JudgeDirection_露光_実測_下げる側を確認して期待どおり() { + var r = HwTestJudge.JudgeDirection(M(158.92, 0.0), M(86.63, 0.0), M(210.14, 0.7018), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(new[] { r.LowerChecked, r.LowerOk }, Is.EqualTo(new[] { true, true })); + } + + [Test] + public void JudgeDirection_露光_実測_上げた側が飽和_確認しない() { + var r = HwTestJudge.JudgeDirection(M(158.92, 0.0), M(86.63, 0.0), M(210.14, 0.7018), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.RaiseChecked, Is.False); + } + + [Test] + public void JudgeDirection_露光_実測_説明に飽和のため確認しないと飽和割合が出る() { + var r = HwTestJudge.JudgeDirection(M(158.92, 0.0), M(86.63, 0.0), M(210.14, 0.7018), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Summary, Does.Contain("飽和のため確認しない(飽和割合 70.18%")); + } + + [Test] + public void JudgeDirection_露光_比が境界ちょうど_合格() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(80, 0), M(125, 0), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Pass, Is.True); + } + + [Test] + public void JudgeDirection_露光_下げても暗くならない_不合格() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(81, 0), M(150, 0), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Pass, Is.False); + } + + [Test] + public void JudgeDirection_露光_上げても明るくならない_不合格() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(50, 0), M(124, 0), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Pass, Is.False); + } + + [Test] + public void JudgeDirection_露光_上げた側が飽和_比が足りなくても不合格にしない() { + // 飽和で頭打ちになり比 1.1 だが,飽和しているので確認しない(下げる側の合格で合格) + var r = HwTestJudge.JudgeDirection(M(100, 0), M(50, 0), M(110, 0.3), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Pass, Is.True); + } + + [Test] + public void JudgeDirection_露光_上げた側が飽和_比を満たしても期待どおりとしない() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(50, 0), M(200, 0.3), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.RaiseOk, Is.False); + } + + [Test] + public void JudgeDirection_露光_下げた側が飽和_確認しない() { + var r = HwTestJudge.JudgeDirection(M(200, 0), M(150, 0.05), M(255, 0), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.LowerChecked, Is.False); + } + + [Test] + public void JudgeDirection_露光_下げた側が飽和_説明に飽和のため確認しないが出る() { + var r = HwTestJudge.JudgeDirection(M(200, 0), M(150, 0.05), M(255, 0), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Summary, Does.Contain("下げる: 飽和のため確認しない(飽和割合")); + } + + [Test] + public void JudgeDirection_露光_飽和割合がしきい値ちょうど_確認する() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(50, 0), M(150, 0.01), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.RaiseChecked, Is.True); + } + + [Test] + public void JudgeDirection_露光_両側とも飽和_不合格() { + var r = HwTestJudge.JudgeDirection(M(200, 0), M(150, 0.05), M(255, 0.5), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Pass, Is.False); + } + + [Test] + public void JudgeDirection_露光_両側とも確認できない_説明に出る() { + var r = HwTestJudge.JudgeDirection(M(200, 0), M(150, 0.05), M(255, 0.5), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Summary, Does.Contain("どちらの向きも確認できません")); + } + + [Test] + public void JudgeDirection_露光_設定が変わらなかった向き_確認しない() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(100, 0), M(150, 0), false, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.LowerChecked, Is.False); + } + + [Test] + public void JudgeDirection_露光_片側だけ設定が変わり期待どおり_合格() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(100, 0), M(150, 0), false, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Pass, Is.True); + } + + [Test] + public void JudgeDirection_露光_基準が真っ暗_両側とも確認せず不合格() { + var r = HwTestJudge.JudgeDirection(M(3, 0), M(2, 0), M(8, 0), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(new[] { r.LowerChecked, r.RaiseChecked, r.Pass }, Is.EqualTo(new[] { false, false, false })); + } + + [Test] + public void JudgeDirection_露光_基準が飽和_上げる向きを確認しない() { + var r = HwTestJudge.JudgeDirection(M(240, 0.2), M(120, 0), M(250, 0.2), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.RaiseChecked, Is.False); + } + + [Test] + public void JudgeDirection_露光_下げた側が測定できない_確認しない() { + var r = HwTestJudge.JudgeDirection(M(100, 0), HwTestMeasurement.NotMeasured, M(150, 0), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.LowerChecked, Is.False); + } + + [Test] + public void JudgeDirection_基準が測定できない_不合格() { + var r = HwTestJudge.JudgeDirection(HwTestMeasurement.NotMeasured, M(50, 0), M(150, 0), true, true, HwTestJudge.ExposureCriteria()); + Assert.That(r.Pass, Is.False); + } + + [Test] + public void JudgeDirection_基準がnull_ArgumentNullException() { + Assert.Throws((Action)(() => + HwTestJudge.JudgeDirection(M(100, 0), M(50, 0), M(150, 0), true, true, null))); + } + + // ================================================================ + // 方向の判定(ゲイン: 差・片側だけでも合格) + // ================================================================ + + [Test] + public void GainCriteria_差で見て3以上_片側で合格() { + var c = HwTestJudge.GainCriteria(); + Assert.That(new object[] { c.UseRatio, c.LowerLimit, c.RaiseLimit, c.MaxSaturatedFraction, c.RequireAllChecked }, + Is.EqualTo(new object[] { false, 3.0, 3.0, 0.01, false })); + } + + [Test] + public void JudgeDirection_ゲイン_実測_上げた側が飽和でも下げる側で合格() { + // 実測: 基準 158.88 → 下げた 143.27(差 -15.6).上げた 206.02 は飽和割合 68.37% + var r = HwTestJudge.JudgeDirection(M(158.88, 0.0), M(143.27, 0.0), M(206.02, 0.6837), true, true, HwTestJudge.GainCriteria()); + Assert.That(r.Pass, Is.True); + } + + [Test] + public void JudgeDirection_ゲイン_実測_上げた側は確認しない() { + var r = HwTestJudge.JudgeDirection(M(158.88, 0.0), M(143.27, 0.0), M(206.02, 0.6837), true, true, HwTestJudge.GainCriteria()); + Assert.That(r.RaiseChecked, Is.False); + } + + [Test] + public void JudgeDirection_ゲイン_差が境界ちょうど_期待どおり() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(97, 0), M(103, 0), true, true, HwTestJudge.GainCriteria()); + Assert.That(new[] { r.LowerOk, r.RaiseOk }, Is.EqualTo(new[] { true, true })); + } + + [Test] + public void JudgeDirection_ゲイン_下げる側だけ足りない_片側で合格() { + // 範囲の下端で変化が小さい(DFK 33UX178 の実測: 10→0 で差 -3.2 の付近) + var r = HwTestJudge.JudgeDirection(M(100, 0), M(99, 0), M(110, 0), true, true, HwTestJudge.GainCriteria()); + Assert.That(r.Pass, Is.True); + } + + [Test] + public void JudgeDirection_ゲイン_片側で合格_説明に出る() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(99, 0), M(110, 0), true, true, HwTestJudge.GainCriteria()); + Assert.That(r.Summary, Does.Contain("一方の向きが期待どおりのため合格")); + } + + [Test] + public void JudgeDirection_ゲイン_両側とも足りない_不合格() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(99, 0), M(102, 0), true, true, HwTestJudge.GainCriteria()); + Assert.That(r.Pass, Is.False); + } + + [Test] + public void JudgeDirection_ゲイン_上げた側が飽和で下げる側が足りない_不合格() { + // 上げた側は差 +50 だが飽和しているので確認しない + var r = HwTestJudge.JudgeDirection(M(100, 0), M(99, 0), M(150, 0.5), true, true, HwTestJudge.GainCriteria()); + Assert.That(r.Pass, Is.False); + } + + [Test] + public void JudgeDirection_ゲイン_両側とも確認できない_不合格() { + var r = HwTestJudge.JudgeDirection(M(100, 0), M(100, 0), M(150, 0.5), false, true, HwTestJudge.GainCriteria()); + Assert.That(r.Pass, Is.False); + } + + // ================================================================ + // 光源・復元 + // ================================================================ + + [Test] + public void JudgeLight_実測_点灯168_63消灯15_20_合格() { + Assert.That(HwTestJudge.JudgeLight(168.63, 15.20, HwTestThresholds.MinLightDelta), Is.True); + } + + [Test] + public void JudgeLight_差が下限ちょうど_合格() { + Assert.That(HwTestJudge.JudgeLight(150, 50, 100), Is.True); + } + + [Test] + public void JudgeLight_差が下限未満_不合格() { + Assert.That(HwTestJudge.JudgeLight(149.9, 50, 100), Is.False); + } + + [Test] + public void JudgeLight_消灯の方が明るい_不合格() { + Assert.That(HwTestJudge.JudgeLight(50, 200, 100), Is.False); + } + + [Test] + public void JudgeLight_NaN_不合格() { + Assert.That(HwTestJudge.JudgeLight(double.NaN, 15, 100), Is.False); + } + + [Test] + public void MinLightDelta_100() { + Assert.That(HwTestThresholds.MinLightDelta, Is.EqualTo(100.0)); + } + + [Test] + public void IsSameSetting_相対誤差1パーセント以内_同じ() { + Assert.That(HwTestJudge.IsSameSetting(0.016667, 0.016667 * 1.005, 0.01, 0), Is.True); + } + + [Test] + public void IsSameSetting_相対誤差1パーセント超え_違う() { + Assert.That(HwTestJudge.IsSameSetting(0.016667, 0.016667 * 1.02, 0.01, 0), Is.False); + } + + [Test] + public void IsSameSetting_元が0でも絶対誤差の許容内_同じ() { + // ゲイン 0 dB(範囲 0〜48): 絶対誤差の許容 48 × 0.1% = 0.048 + Assert.That(HwTestJudge.IsSameSetting(0, 0.04, 0.01, 48 * HwTestThresholds.GainRestoreRangeFraction), Is.True); + } + + [Test] + public void IsSameSetting_元が0で絶対誤差の許容超え_違う() { + Assert.That(HwTestJudge.IsSameSetting(0, 0.05, 0.01, 48 * HwTestThresholds.GainRestoreRangeFraction), Is.False); + } + + [Test] + public void IsSameSetting_相対と絶対の大きい方を許容にする() { + // 元 1 → 1.04: 相対の許容は約 0.0104 だが,絶対の許容 0.048 の方が大きい + Assert.That(HwTestJudge.IsSameSetting(1, 1.04, 0.01, 0.048), Is.True); + } + + [Test] + public void IsSameSetting_NaN_違う() { + Assert.That(HwTestJudge.IsSameSetting(1, double.NaN, 0.01, 0.048), Is.False); + } + + [Test] + public void RestoreTolerances_相対1パーセント_ゲインは範囲の0_1パーセント() { + Assert.That(new[] { HwTestThresholds.RestoreRelativeTolerance, HwTestThresholds.GainRestoreRangeFraction }, + Is.EqualTo(new[] { 0.01, 0.001 })); + } + + [Test] + public void IsRestored_許容内_戻った() { + // 実測: 最初 168.73 → 元に戻した後 168.64 + Assert.That(HwTestJudge.IsRestored(168.73, 168.64, HwTestThresholds.RestoreTolerance), Is.True); + } + + [Test] + public void IsRestored_許容ちょうど_戻った() { + Assert.That(HwTestJudge.IsRestored(100, 110, 10), Is.True); + } + + [Test] + public void IsRestored_許容超え_戻っていない() { + Assert.That(HwTestJudge.IsRestored(100, 110.5, 10), Is.False); + } + + [Test] + public void IsRestored_NaN_戻っていない() { + Assert.That(HwTestJudge.IsRestored(100, double.NaN, 10), Is.False); + } + + [Test] + public void RelativeError_差を元の絶対値で割る() { + Assert.That(HwTestJudge.RelativeError(-2, -1), Is.EqualTo(0.5).Within(Eps)); + } + + [Test] + public void RelativeError_NaN_NaN() { + Assert.That(HwTestJudge.RelativeError(double.NaN, 1), Is.NaN); + } + + [Test] + public void RelativeError_元も読み戻しも0_0() { + Assert.That(HwTestJudge.RelativeError(0, 0), Is.EqualTo(0.0)); + } + + [Test] + public void RelativeError_元が0で読み戻しが0でない_正の無限大() { + Assert.That(HwTestJudge.RelativeError(0, 1), Is.EqualTo(double.PositiveInfinity)); + } + + [Test] + public void DescribeRestoredValue_丸めない値を往復できる書式で出す() { + var v = 1.0 / 60; + Assert.That(HwTestJudge.DescribeRestoredValue("露光", v, v), Does.Contain("読み戻し " + v.ToString("R", CultureInfo.InvariantCulture))); + } + + [Test] + public void DescribeRestoredValue_一致_相対誤差0パーセント() { + Assert.That(HwTestJudge.DescribeRestoredValue("露光", 0.5, 0.5), Does.Contain("相対誤差 0%")); + } + + [Test] + public void DescribeRestoredValue_元が0_元が0と出る() { + Assert.That(HwTestJudge.DescribeRestoredValue("ゲイン", 0, 0.01), Does.Contain("元が 0")); + } + + [Test] + public void DescribeRestoredValue_NaN_測定できないと出る() { + Assert.That(HwTestJudge.DescribeRestoredValue("ゲイン", 1, double.NaN), Does.Contain("測定できない")); + } + + [Test] + public void DescribeRestoredValue_小数点がカンマの文化でもピリオドで出す() { + var saved = Thread.CurrentThread.CurrentCulture; + string text; + try { + Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); + text = HwTestJudge.DescribeRestoredValue("露光", 0.25, 0.25); + } finally { + Thread.CurrentThread.CurrentCulture = saved; + } + Assert.That(text, Does.Contain("読み戻し 0.25(元 0.25.")); + } + + // ================================================================ + // 校正の停止 + // ================================================================ + + [Test] + public void ShouldSuppressCalibration_チャート不要の検査_止める() { + Assert.That(HwTestJudge.ShouldSuppressCalibration(false), Is.True); + } + + [Test] + public void ShouldSuppressCalibration_チャートが必要な検査_止めない() { + Assert.That(HwTestJudge.ShouldSuppressCalibration(true), Is.False); + } + + [Test] + public void IsCalibrationLeaked_止めているのに校正中_漏れ() { + Assert.That(HwTestJudge.IsCalibrationLeaked(true, true, false), Is.True); + } + + [Test] + public void IsCalibrationLeaked_止めているのに校正済み_漏れ() { + Assert.That(HwTestJudge.IsCalibrationLeaked(true, false, true), Is.True); + } + + [Test] + public void IsCalibrationLeaked_止めていて校正なし_漏れでない() { + Assert.That(HwTestJudge.IsCalibrationLeaked(true, false, false), Is.False); + } + + [Test] + public void IsCalibrationLeaked_止めていない_校正中でも漏れでない() { + Assert.That(HwTestJudge.IsCalibrationLeaked(false, true, true), Is.False); + } + + // ================================================================ + // 終了コードと作業フォルダ + // ================================================================ + + [Test] + public void DecideExitCode_機種が見つからない_不合格数によらず2() { + Assert.That(HwTestJudge.DecideExitCode(true, 3), Is.EqualTo(2)); + } + + [Test] + public void DecideExitCode_不合格あり_1() { + Assert.That(HwTestJudge.DecideExitCode(false, 1), Is.EqualTo(1)); + } + + [Test] + public void DecideExitCode_すべて合格_0() { + Assert.That(HwTestJudge.DecideExitCode(false, 0), Is.EqualTo(0)); + } + + [Test] + public void ExitCodes_0合格_1不合格_2異常_3タイムアウト() { + Assert.That(new[] { HwTestExitCodes.Pass, HwTestExitCodes.Fail, HwTestExitCodes.Error, HwTestExitCodes.Timeout }, + Is.EqualTo(new[] { 0, 1, 2, 3 })); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void ShouldKeepWorkDir_keep指定_常に残す(int exitCode) { + Assert.That(HwTestJudge.ShouldKeepWorkDir(true, exitCode), Is.True); + } + + [TestCase(1)] + [TestCase(3)] + public void ShouldKeepWorkDir_不合格とタイムアウト_残す(int exitCode) { + Assert.That(HwTestJudge.ShouldKeepWorkDir(false, exitCode), Is.True); + } + + [TestCase(0)] + [TestCase(2)] + public void ShouldKeepWorkDir_合格と前提不足_消す(int exitCode) { + Assert.That(HwTestJudge.ShouldKeepWorkDir(false, exitCode), Is.False); + } + } +} +#endif diff --git a/TIASshot.Tests/HwTestOptionsTests.cs b/TIASshot.Tests/HwTestOptionsTests.cs new file mode 100644 index 0000000..7e9c80c --- /dev/null +++ b/TIASshot.Tests/HwTestOptionsTests.cs @@ -0,0 +1,223 @@ +#if HWTEST +using System; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// 実機テストの引数(HwTestOptions)の解析のテスト(層①). + /// + /// 仕様: PLAN_06「仕組み」・.claude/verify-profile.md「実機テスト」 + /// - 起動: TIASshot.exe --hwtest --camera <lucam|iscam> [--with-chart] [--hwtest-out <結果ファイル>] [--hwtest-keep] + /// - --camera は必須.機種は lucam / iscam のみ(指定した機種だけを使う) + /// - 未知のフラグ・値の欠落・重複・不正な機種は誤り(打ち間違いで別の検査が走らない) + /// - 先頭要素が実行ファイルのパス(Environment.GetCommandLineArgs の形)でもよい + /// 本クラスは HWTEST 構成(Debug|x64)でのみコンパイルされる. + /// + [TestFixture] + public class HwTestOptionsTests { + + private const string Exe = @"C:\app\TIASshot.exe"; + + private static HwTestOptions ParseOk(params string[] args) { + string error; + var options = HwTestOptions.Parse(args, out error); + Assert.That(error, Is.Null, "前提: 解析が成功すること"); + Assert.That(options, Is.Not.Null, "前提: 解析が成功すること"); + return options; + } + + private static string ParseError(params string[] args) { + string error; + var options = HwTestOptions.Parse(args, out error); + Assert.That(options, Is.Null, "誤りのときは null を返すこと"); + return error; + } + + // --------------------------------------------------------------- + // IsRequested + // --------------------------------------------------------------- + + [Test] + public void IsRequested_hwtestあり_true() { + Assert.That(HwTestOptions.IsRequested(new[] { Exe, "--hwtest", "--camera", "lucam" }), Is.True); + } + + [Test] + public void IsRequested_hwtestなし_false() { + Assert.That(HwTestOptions.IsRequested(new[] { Exe, "--selftest" }), Is.False); + } + + [Test] + public void IsRequested_null_false() { + Assert.That(HwTestOptions.IsRequested(null), Is.False); + } + + [Test] + public void IsRequested_空配列_false() { + Assert.That(HwTestOptions.IsRequested(new string[0]), Is.False); + } + + // --------------------------------------------------------------- + // TryParseCamera + // --------------------------------------------------------------- + + [Test] + public void TryParseCamera_lucam_Lucam() { + HwTestCameraKind kind; + var ok = HwTestOptions.TryParseCamera("lucam", out kind); + Assert.That(ok && kind == HwTestCameraKind.Lucam, Is.True); + } + + [Test] + public void TryParseCamera_iscam_IScam() { + HwTestCameraKind kind; + var ok = HwTestOptions.TryParseCamera("iscam", out kind); + Assert.That(ok && kind == HwTestCameraKind.IScam, Is.True); + } + + [Test] + public void TryParseCamera_大文字_受け付ける() { + HwTestCameraKind kind; + var ok = HwTestOptions.TryParseCamera("ISCAM", out kind); + Assert.That(ok && kind == HwTestCameraKind.IScam, Is.True); + } + + [TestCase("lumenera")] + [TestCase("")] + [TestCase(" lucam")] + public void TryParseCamera_不正な機種_false(string value) { + HwTestCameraKind kind; + Assert.That(HwTestOptions.TryParseCamera(value, out kind), Is.False); + } + + [Test] + public void TryParseCamera_null_false() { + HwTestCameraKind kind; + Assert.That(HwTestOptions.TryParseCamera(null, out kind), Is.False); + } + + // --------------------------------------------------------------- + // Parse: 正常系 + // --------------------------------------------------------------- + + [Test] + public void Parse_cameraのみ_機種が設定される() { + var o = ParseOk("--hwtest", "--camera", "iscam"); + Assert.That(o.Camera, Is.EqualTo(HwTestCameraKind.IScam)); + } + + [Test] + public void Parse_先頭が実行ファイルのパス_受け付ける() { + var o = ParseOk(Exe, "--hwtest", "--camera", "lucam"); + Assert.That(o.Camera, Is.EqualTo(HwTestCameraKind.Lucam)); + } + + [Test] + public void Parse_省略時_既定値() { + var o = ParseOk(Exe, "--hwtest", "--camera", "lucam"); + Assert.That(new object[] { o.OutPath, o.WithChart, o.Keep }, Is.EqualTo(new object[] { null, false, false })); + } + + [Test] + public void Parse_hwtestOut_出力先が設定される() { + var o = ParseOk(Exe, "--hwtest", "--camera", "lucam", "--hwtest-out", @"C:\tmp\result.txt"); + Assert.That(o.OutPath, Is.EqualTo(@"C:\tmp\result.txt")); + } + + [Test] + public void Parse_withChart_true() { + var o = ParseOk(Exe, "--hwtest", "--camera", "lucam", "--with-chart"); + Assert.That(o.WithChart, Is.True); + } + + [Test] + public void Parse_hwtestKeep_true() { + var o = ParseOk(Exe, "--hwtest", "--camera", "lucam", "--hwtest-keep"); + Assert.That(o.Keep, Is.True); + } + + [Test] + public void Parse_順序が入れ替わっても_同じ結果() { + var o = ParseOk(Exe, "--hwtest-out", "r.txt", "--with-chart", "--camera", "iscam", "--hwtest", "--hwtest-keep"); + Assert.That(new object[] { o.Camera, o.OutPath, o.WithChart, o.Keep }, + Is.EqualTo(new object[] { HwTestCameraKind.IScam, "r.txt", true, true })); + } + + [Test] + public void Parse_機種名の大文字_受け付ける() { + var o = ParseOk(Exe, "--hwtest", "--camera", "LuCam"); + Assert.That(o.Camera, Is.EqualTo(HwTestCameraKind.Lucam)); + } + + // --------------------------------------------------------------- + // Parse: 誤り + // --------------------------------------------------------------- + + [Test] + public void Parse_null_誤り() { + Assert.That(ParseError(null), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_cameraなし_誤り() { + Assert.That(ParseError(Exe, "--hwtest"), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_cameraの値の欠落_末尾_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera"), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_cameraの値の欠落_次がフラグ_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "--with-chart"), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_hwtestOutの値の欠落_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lucam", "--hwtest-out"), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_hwtestOutの値が空白_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lucam", "--hwtest-out", " "), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_不正な機種_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lumenera"), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_不正な機種_誤りの理由に値が含まれる() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lumenera"), Does.Contain("lumenera")); + } + + [Test] + public void Parse_cameraの重複_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lucam", "--camera", "iscam"), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_hwtestOutの重複_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lucam", "--hwtest-out", "a.txt", "--hwtest-out", "b.txt"), + Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_未知のフラグ_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lucam", "--with-chrat"), Is.Not.Null.And.Not.Empty); + } + + [Test] + public void Parse_未知のフラグ_誤りの理由にフラグが含まれる() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lucam", "--with-chrat"), Does.Contain("--with-chrat")); + } + + [Test] + public void Parse_先頭以外の余分な値_誤り() { + Assert.That(ParseError(Exe, "--hwtest", "--camera", "lucam", "extra"), Is.Not.Null.And.Not.Empty); + } + } +} +#endif diff --git a/TIASshot.Tests/IScamDeviceSelectorTests.cs b/TIASshot.Tests/IScamDeviceSelectorTests.cs new file mode 100644 index 0000000..9458d6c --- /dev/null +++ b/TIASshot.Tests/IScamDeviceSelectorTests.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// ImagingSource の接続先デバイスの選択(IScamDeviceSelector)のテスト(層①.IScam から切り出した判断ロジック). + /// + /// 仕様: PLAN_06「カメラの選択」(両機種を同時につないでも指定した機種だけを使う)・IScamDeviceSelector の契約 + /// - DeviceName(デバイス状態ファイル名・設定キー)は列挙名から空白を除いたもの + /// - キーが(大文字小文字を区別して)一致する最初の列挙名(空白入りの元の名前)を返す.見つからなければ null + /// + [TestFixture] + public class IScamDeviceSelectorTests { + + [Test] + public void ToDeviceKey_空白を除く() { + Assert.That(IScamDeviceSelector.ToDeviceKey("DFK 33UX178"), Is.EqualTo("DFK33UX178")); + } + + [Test] + public void ToDeviceKey_複数の空白_すべて除く() { + Assert.That(IScamDeviceSelector.ToDeviceKey(" DFK 33 UX178 "), Is.EqualTo("DFK33UX178")); + } + + [Test] + public void ToDeviceKey_null_null() { + Assert.That(IScamDeviceSelector.ToDeviceKey(null), Is.Null); + } + + [Test] + public void FindDeviceName_キーが一致する元の名前を返す() { + var names = new[] { "DFK 23UX249", "DFK 33UX178" }; + Assert.That(IScamDeviceSelector.FindDeviceName(names, "DFK33UX178"), Is.EqualTo("DFK 33UX178")); + } + + [Test] + public void FindDeviceName_同じキーが複数_最初の名前() { + var names = new[] { "DFK 33UX178", "DFK33UX178" }; + Assert.That(IScamDeviceSelector.FindDeviceName(names, "DFK33UX178"), Is.EqualTo("DFK 33UX178")); + } + + [Test] + public void FindDeviceName_大文字小文字が違う_一致しない() { + var names = new[] { "DFK 33UX178" }; + Assert.That(IScamDeviceSelector.FindDeviceName(names, "dfk33ux178"), Is.Null); + } + + [Test] + public void FindDeviceName_一致なし_null() { + var names = new[] { "DFK 23UX249" }; + Assert.That(IScamDeviceSelector.FindDeviceName(names, "DFK33UX178"), Is.Null); + } + + [Test] + public void FindDeviceName_一覧がnull_null() { + Assert.That(IScamDeviceSelector.FindDeviceName(null, "DFK33UX178"), Is.Null); + } + + [Test] + public void FindDeviceName_一覧が空_null() { + Assert.That(IScamDeviceSelector.FindDeviceName(new List(), "DFK33UX178"), Is.Null); + } + + [Test] + public void FindDeviceName_キーがnull_null() { + Assert.That(IScamDeviceSelector.FindDeviceName(new[] { "DFK 33UX178" }, null), Is.Null); + } + + [Test] + public void FindDeviceName_キーが空_null() { + // 一覧に空の名前があっても空のキーには一致させない + Assert.That(IScamDeviceSelector.FindDeviceName(new[] { "", "DFK 33UX178" }, ""), Is.Null); + } + + [Test] + public void FindDeviceName_一覧中のnullと空を飛ばす() { + var names = new[] { null, "", "DFK 33UX178" }; + Assert.That(IScamDeviceSelector.FindDeviceName(names, "DFK33UX178"), Is.EqualTo("DFK 33UX178")); + } + } +} diff --git a/TIASshot.Tests/LightSourceTests.cs b/TIASshot.Tests/LightSourceTests.cs new file mode 100644 index 0000000..995bb52 --- /dev/null +++ b/TIASshot.Tests/LightSourceTests.cs @@ -0,0 +1,35 @@ +using System; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// 光源(LightSource)のうち実機なしで確かめられる部分のテスト(層①). + /// + /// 仕様: PLAN_06「守ること」・.claude/verify-profile.md「実機テスト」の後始末 + /// - Close は消灯せずにポートを閉じる.開いていなければ何もしない(例外にならない) + /// - 終了処理・実機テストの後始末から何度呼ばれても止めない + /// ポートを開いた状態で消灯の命令を送らないことは実機でしか確かめられない(層④.実機テストの cleanup). + /// + [TestFixture] + public class LightSourceTests { + + [Test] + public void Close_ポートが開いていない_例外にならない() { + var light = new LightSource(); + Assert.DoesNotThrow((Action)(() => light.Close())); + } + + [Test] + public void Close_2回呼ぶ_例外にならない() { + var light = new LightSource(); + Assert.DoesNotThrow((Action)(() => { light.Close(); light.Close(); })); + } + + [Test] + public void Close_ポートが開いていない_閉じた状態のまま() { + var light = new LightSource(); + light.Close(); + Assert.That(light.IsOpen, Is.False); + } + } +} diff --git a/TIASshot.Tests/SelfTestConfigTests.cs b/TIASshot.Tests/SelfTestConfigTests.cs new file mode 100644 index 0000000..70f74b4 --- /dev/null +++ b/TIASshot.Tests/SelfTestConfigTests.cs @@ -0,0 +1,78 @@ +#if SELFTEST +using System; +using System.IO; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// 一時設定ファイルの準備(SelfTestConfig.Prepare)の書き出し先のファイル名のテスト. + /// + /// 仕様: .claude/verify-profile.md「実機テスト」の後始末(実機テストの一時設定ファイルは config.hwtest.xml)・PLAN_06「守ること」 + /// - fileName を省略したときは従来どおり config.selftest.xml に書き出す + /// - fileName に config.hwtest.xml を渡すとそのファイルに書き出し,config.selftest.xml は作らない + /// - 同梱の config.xml は読むだけ.作業は一時フォルダで行い,テストの終わりに削除する + /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる. + /// + [TestFixture] + public class SelfTestConfigTests { + + private string _sourceDir; + private string _workDir; + + [SetUp] + public void SetUp() { + _sourceDir = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "TIASshot")); + Assert.That(File.Exists(Path.Combine(_sourceDir, "config.xml")), Is.True, $"前提: 同梱 config.xml があること: {_sourceDir}"); + _workDir = Path.Combine(Path.GetTempPath(), "TIASshot-it", "selftestconfig-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() { + if (Directory.Exists(_workDir)) Directory.Delete(_workDir, true); + } + + private SelfTestConfig.Prepared Prepare(string fileName) { + var source = Path.Combine(_sourceDir, "config.xml"); + return fileName == null + ? SelfTestConfig.Prepare(source, _sourceDir, _workDir) + : SelfTestConfig.Prepare(source, _sourceDir, _workDir, null, fileName); + } + + [Test] + public void Prepare_ファイル名の省略_configSelftestXmlのパスを返す() { + var prepared = Prepare(null); + Assert.That(prepared.ConfigPath, Is.EqualTo(Path.Combine(_workDir, "config.selftest.xml"))); + } + + [Test] + public void Prepare_ファイル名の省略_configSelftestXmlが作られる() { + Prepare(null); + Assert.That(File.Exists(Path.Combine(_workDir, "config.selftest.xml")), Is.True); + } + + [Test] + public void Prepare_configHwtestXmlを指定_そのパスを返す() { + var prepared = Prepare("config.hwtest.xml"); + Assert.That(prepared.ConfigPath, Is.EqualTo(Path.Combine(_workDir, "config.hwtest.xml"))); + } + + [Test] + public void Prepare_configHwtestXmlを指定_そのファイルが作られる() { + Prepare("config.hwtest.xml"); + Assert.That(File.Exists(Path.Combine(_workDir, "config.hwtest.xml")), Is.True); + } + + [Test] + public void Prepare_configHwtestXmlを指定_configSelftestXmlは作られない() { + Prepare("config.hwtest.xml"); + Assert.That(File.Exists(Path.Combine(_workDir, "config.selftest.xml")), Is.False); + } + + [Test] + public void Prepare_configHwtestXmlを指定_保存先は作業フォルダ配下() { + var prepared = Prepare("config.hwtest.xml"); + Assert.That(Path.GetFullPath(prepared.SaveRoot), Does.StartWith(Path.GetFullPath(_workDir))); + } + } +} +#endif diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index bcc7a6b..f745aa4 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -24,8 +24,8 @@ true bin\x64\Debug\ - - DEBUG;TRACE;SELFTEST + + DEBUG;TRACE;SELFTEST;HWTEST full x64 7.3 @@ -96,7 +96,12 @@ + + + + + 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); } diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index baedb6c..52fb3fa 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -52,5 +52,9 @@ **Lumenera SDK** - 用途: Lumenera 製カメラの制御(Lw110) -- DLL: `lumenera.api.dll`(リポジトリ内に同梱) -- 別途インストール不要(同梱 DLL を参照) +- DLL: `lumenera.api.dll`(.NET 側.リポジトリ内に同梱) +- ビルド・セルフテストは同梱 DLL だけでできる.実機を使うにはネイティブの `lucamapi.dll` を含む LuCam Software の導入が別途要る([ENV_02](ENV_02_環境構築手順.md) の「カメラ・光源のドライバ」) + +**カメラ・光源のドライバ(実機を使う場合に別途インストール)** + +- LuCam Software(Lumenera)・Cam33U Driver(ImagingSource の 33U/37U/38U.IC Imaging Control SDK とは別)・CP210x Universal Windows Driver(光源の USB-シリアル変換).入手先・バージョンは [ENV_02](ENV_02_環境構築手順.md) を参照 diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index a31ad7f..dec5bcc 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -29,6 +29,8 @@ 5. スクリプト完了後,IC Imaging Control SDK を手動でインストールする. - 詳細は「必要なソフトウェア — IC Imaging Control SDK」を参照する. +6. 実機(カメラ・光源)を開発 PC で使う場合は,カメラと光源のドライバを手動でインストールする. + - 詳細は「必要なソフトウェア — カメラ・光源のドライバ」を参照する. **自動化される内容** @@ -40,6 +42,7 @@ **自動化されない内容(手動対応が必要)** - IC Imaging Control SDK のインストール +- カメラ・光源のドライバのインストール(実機を使う場合) - 実行時設定ファイルの配置(config.xml,カメラ設定 XML,CSV) ### 手動セットアップ @@ -63,7 +66,26 @@ ### Lumenera SDK - Lumenera 製カメラ(Lw110)の使用に必要. -- `lumenera.api.dll` はリポジトリ内に同梱されている. +- リポジトリに同梱されている `lumenera.api.dll` は .NET 側のラッパーだけで,これだけでは実機を使えない.ネイティブの `lucamapi.dll` は下記「カメラ・光源のドライバ」の LuCam Software で導入する. +- ※ ビルドとセルフテスト(偽カメラ)は同梱 DLL だけでできる. + +### カメラ・光源のドライバ + +開発 PC で実機(カメラ・光源)を使う場合(実機テストを含む)に必要.いずれも導入には管理者権限(UAC)が要る.2026-10-08 に以下のバージョンで導入・動作確認した. + +| 対象 | ドライバ(確認したバージョン) | 入手先 | ログイン | +| --- | --- | --- | --- | +| Lumenera 製カメラ(Lw110) | LuCam Software and Software Development Kit (Windows) 6.9.0 | [Teledyne Vision Solutions](https://teledynevisionsolutions.com/products/lt-series-usb3/GetResourcesSupportDownloads) の Resources & Support → Downloads タブ | 要(アカウント) | +| ImagingSource 製カメラ(DFK33UX178 等 33U/37U/38U) | Device Driver for USB 33U, 37U, 38U Cameras(Cam33U Driver and Tools)6.0.0.2811 | [The Imaging Source](https://www.theimagingsource.com/en-us/support/download/icwdmuvccamtis33u/) | 不要 | +| 光源(USB-シリアル変換 Silicon Labs CP2102) | CP210x Universal Windows Driver 11.6.0.420(zip) | [Silicon Labs](https://silabs.com/developers/usb-to-uart-bridge-vcp-drivers) | 不要 | + +- **LuCam Software**: 導入するとネイティブの `lucamapi.dll` が `System32`/`SysWOW64` に入る.Lw110(USB 2.0)への対応はダウンロードページに明記されていない. +- **Cam33U Driver**: IC Imaging Control SDK(.NET ライブラリ)とは別に要る.無いとカメラが Windows 標準の汎用カメラとして認識され,「USB3 Vision Device」がエラーになって IC Imaging Control から使えない. +- **CP210x Driver**: スクリプト(`curl` 等)からのダウンロードは 403 で拒否されるため,ブラウザで落とす.zip を展開して導入する. +- 導入後の確認(デバイスマネージャー): + - ImagingSource: カメラがエラー表示(「USB3 Vision Device」の警告)なしで認識されている. + - 光源: 「ポート (COM と LPT)」に「Silicon Labs CP210x USB to UART Bridge (COMn)」が現れる.アプリは `config.xml` の `LightSource/Device`(`Silicon Labs CP210x`)でこの名前を探して接続する. + - Lumenera: `C:\Windows\System32\lucamapi.dll` があり,カメラがエラー表示なしで認識されている. ### Python と bash(Claude Code のフック用) diff --git "a/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 6d5aeac..7af118b 100644 --- "a/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -36,7 +36,8 @@ | SDK | 用途 | 入手・配置 | | --- | --- | --- | | IC Imaging Control SDK | ImagingSource 製カメラ(DFK33UX178 / DFK23UX249) | [The Imaging Source](https://www.theimagingsource.com/en-us/support/download/) から各自インストール(`setup.ps1` が未導入時にダウンロードページを開く) | -| Lumenera SDK | Lumenera 製カメラ(Lw110) | `lumenera.api.dll` をリポジトリに同梱済み.追加インストールは不要 | +| Lumenera SDK | Lumenera 製カメラ(Lw110) | .NET 側の `lumenera.api.dll` はリポジトリに同梱済み(ビルド・セルフテストはこれだけでできる).実機を使うにはネイティブの `lucamapi.dll` を含む LuCam Software(Teledyne Vision Solutions.ダウンロードにアカウントが要る)を各自インストールする | +| カメラ・光源のドライバ | 実機(カメラ・光源)を開発 PC で使う場合 | LuCam Software(Lumenera)・Cam33U Driver(ImagingSource)・CP210x Universal Windows Driver(光源)を各自インストールする(管理者権限が要る).入手先・バージョンは [ENV_02](ENV_02_環境構築手順.md) の「カメラ・光源のドライバ」 | ## 実行時参照ファイルの配布 (Runtime Reference Files) diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index c29a8e9..cc0e245 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -148,3 +148,37 @@ - ランナーがクリーンするのは Debug 出力(`TIASshot/` と `TIASshot.Tests/` の `bin/x64/Debug`・`obj/x64/Debug`)のみ.`bin/x64/Release` 等ほかの構成の出力は消さない.`--no-clean`・`--skip-tests` は開発時の短縮用で,検証の判定には使わない. - `--selftest-keep` を付けると,合格時も作業フォルダ(`%TEMP%\TIASshot-selftest\work-*`.校正データ・保存画像)を残す(不合格時は常に残る). - verifier(`/verify`)による自動検証の手順・判定の読み方は `.claude/verify-profile.md` を参照する. + +## 実機テスト (Hardware Test) + +実機のカメラと光源を操作して,接続・プレビュー・露光/ゲイン・光源・閉じる処理・再接続を検査する(`Debug|x64` のみ.[ENV_06](ENV_06_ビルド構成とビルドフラグ.md) の `HWTEST`.計画は [PLAN_06](../03_PLAN/PLAN_06_実機自動テスト計画.md)).実際の `Form1` が表示される. + +**ユーザーの合図があるときだけ実行する**(撮影の本番中に機材を操作しないため.Claude は合図なしに実行しない). + +前提: + +- カメラ・光源のドライバが入っている([ENV_02](ENV_02_環境構築手順.md) の「カメラ・光源のドライバ」) +- 検査する機種のカメラと光源が開発 PC に接続されている +- カメラの前の穴にカラーチャート(または白板)がはめてある(チャート不要の検査の間は自動校正を止める) +- TIASshot が起動していない(起動中ならランナーは実行せずに終了コード 2 を返す) + +```powershell +# ビルド(Debug|x64)→ 実機テスト(結果は .verify/hwtest/run-<日時>/).2 機種とも行う場合は機種ごとに実行する +python scripts/verify/run_hwtest.py --camera lucam +python scripts/verify/run_hwtest.py --camera iscam +# 開発中の素早い再確認(Debug 出力を消さない.判定には使わない) +python scripts/verify/run_hwtest.py --camera iscam --no-clean + +# 実機テスト単体(WinExe のため Start-Process -Wait で終了コードを受け取る.実行ファイルのフォルダで実行する) +$p = Start-Process "TIASshot\bin\x64\Debug\TIASshot.exe" -WorkingDirectory "TIASshot\bin\x64\Debug" -ArgumentList "--hwtest","--camera","iscam","--hwtest-out","$env:TEMP\tias-hwtest.txt" -Wait -PassThru +$p.ExitCode # 0=全項目合格,1=不合格あり,2=実機テスト自体の異常,3=タイムアウト +Get-Content "$env:TEMP\tias-hwtest.txt" # 各項目の PASS/FAIL と測定値(UTF-8 BOM 付き) +``` + +- 書式: `python scripts/verify/run_hwtest.py --camera [--with-chart] [--no-clean]`.`--with-chart`(チャートが必要な検査.PLAN_06 フェーズ 2)は未実装で,指定すると機材に触れずに終了コード 2 になる. +- 単体の書式: `TIASshot.exe --hwtest --camera [--hwtest-out <結果ファイル>] [--with-chart] [--hwtest-keep]`.`--hwtest-keep` を付けると合格時も作業フォルダ(`%TEMP%\TIASshot-hwtest\work-*`)を残す(不合格・タイムアウト時は常に残る). +- 出力先: `.verify/hwtest/run-<日時>/`(`build.log`・`hwtest-result.txt`・`summary.json`). +- ランナーの終了コード: `0`=全項目合格,`1`=不合格あり(ビルド失敗・検査の不合格・タイムアウト),`2`=前提条件を満たさない(ツール未導入・TIASshot 起動中・指定機種が見つからない・引数不正・`--with-chart`). +- 実機テスト単体の終了コード(`summary.json` の `hwtest_exit_code`): `0`=全項目合格,`1`=不合格あり,`2`=実機テスト自体の異常(引数不正・指定機種が見つからない・未実装の指定),`3`=タイムアウト(各項目の上限または全体 10 分.ランナーでは `1` になる),`null`=ランナーが 720 秒で打ち切った. +- NUnit テスト・セルフテストは実行しない(それぞれ `run_tests.py`・`run_selftest.py`). +- 検査項目・しきい値・判定の読み方は `.claude/verify-profile.md` の「実機テスト」節と [TEST_01](../06_TEST/TEST_01_テスト仕様.md) を参照する. diff --git "a/docs/02_ENV/ENV_06_\343\203\223\343\203\253\343\203\211\346\247\213\346\210\220\343\201\250\343\203\223\343\203\253\343\203\211\343\203\225\343\203\251\343\202\260.md" "b/docs/02_ENV/ENV_06_\343\203\223\343\203\253\343\203\211\346\247\213\346\210\220\343\201\250\343\203\223\343\203\253\343\203\211\343\203\225\343\203\251\343\202\260.md" index 1460d5b..458bf66 100644 --- "a/docs/02_ENV/ENV_06_\343\203\223\343\203\253\343\203\211\346\247\213\346\210\220\343\201\250\343\203\223\343\203\253\343\203\211\343\203\225\343\203\251\343\202\260.md" +++ "b/docs/02_ENV/ENV_06_\343\203\223\343\203\253\343\203\211\346\247\213\346\210\220\343\201\250\343\203\223\343\203\253\343\203\211\343\203\225\343\203\251\343\202\260.md" @@ -5,7 +5,7 @@ ## 設計方針 (Policy) - **Release(配布版)=素の製品**.診断・評価・デモ用の機能はコードごと除外する. -- **Debug=開発・検証版**.診断/評価機能(`MEMMONITOR`・`WHITEBOARD`・`SELFTEST`)を自動で含める. +- **Debug=開発・検証版**.診断/評価機能(`MEMMONITOR`・`WHITEBOARD`・`SELFTEST`・`HWTEST`)を自動で含める. ## ソリューション構成 (Solution Configurations) @@ -13,7 +13,7 @@ | 構成 | 用途 | 定義されるシンボル | 出力先 | | --- | --- | --- | --- | -| `Debug\|x64` | 開発・検証 | `DEBUG` `TRACE` `WHITEBOARD` `MEMMONITOR` `SELFTEST` | `TIASshot/bin/x64/Debug/` | +| `Debug\|x64` | 開発・検証 | `DEBUG` `TRACE` `WHITEBOARD` `MEMMONITOR` `SELFTEST` `HWTEST` | `TIASshot/bin/x64/Debug/` | | `Release\|x64` | 配布 | `TRACE` | `TIASshot/bin/x64/Release/` | > ※ `TIASshot.csproj` には `Debug\|AnyCPU` / `Release\|AnyCPU` の PropertyGroup も残っているが,`.sln` は `x64` のみをビルド対象としており,通常はこれらを使わない. @@ -26,6 +26,7 @@ | `MEMMONITOR` | メモリ観測ウィンドウ+プレビュー計測 | `Debug\|x64`(自動) | ✗ | `UI/MemoryMonitorForm`,`UI/PreviewProfiler`,`UI/TimedPictureBox`,`UI/Form1.MemMonitor`(F9 トグル),`Form1.ShowImage` 等のプレビュー計測 | | `WHITEBOARD` | 白板撮影モード(照明均一性評価用) | `Debug\|x64`(自動) | ✗ | 白板撮影ボタン(`Form1` の専用 partial),`Cameras/IScam.ShotWhiteBoard` 等 | | `SELFTEST` | ヘッドレスのセルフテスト(`--selftest`) | `Debug\|x64`(自動)・`Debug\|AnyCPU`(csproj のみ.通常は未使用) | ✗ | `SelfTest/`(ランナー・偽カメラ・合成チャート),`UI/Form1.SelfTest`・`UI/PreviewMonitor.SelfTest`,`Program.Main` の分岐,`Form1.ShowError` のヘッドレス記録 | +| `HWTEST` | 実機テスト(`--hwtest`.実機のカメラ・光源を操作する) | `Debug\|x64`(自動)・`Debug\|AnyCPU`(csproj のみ.通常は未使用).**`SELFTEST` と同時に定義すること**(無いと `#error`) | ✗ | `HwTest/`(ランナー・判定・引数),`UI/Form1.HwTest`,`Cameras/IScam.HwTest`・`Cameras/Lucam.HwTest`(露光・ゲインの操作),`Program.Main` の分岐,`CameraBase` の校正停止フラグ,`Form1_Load` の自動接続の省略・`Form1.ShowImage` のフレーム記録・`Form1.ShowError` の記録,メモリ観測ウィンドウを開かない分岐 | | `DEBUG` | .NET 標準デバッグシンボル | `Debug\|x64` | ✗ | 標準(一部の開発時分岐) | | `TRACE` | .NET 標準トレースシンボル | 全構成 | ○ | `System.Diagnostics.Trace` | @@ -48,9 +49,17 @@ - 依存更新後などに,人間の実機確認の前段として verifier(`/verify`)が使う.実行手順は [ENV_04_開発コマンド](ENV_04_開発コマンド.md) と `.claude/verify-profile.md` を参照する. - 配布版には不要なため Release では未定義(Release の exe に `--selftest` を渡しても通常起動になる). +### HWTEST — 実機テスト + +- `TIASshot.exe --hwtest --camera ` で実際の `Form1` を表示し,指定した機種のカメラと光源を操作して,接続・プレビュー・露光/ゲイン・光源・閉じる処理・再接続を検査する([PLAN_06](../03_PLAN/PLAN_06_実機自動テスト計画.md)).終了コード(0=合格)と結果ファイルで返す.保存先は一時フォルダに差し替え,同梱の `config.xml`・デバイス状態ファイルは書き換えない. +- チャート不要の検査の間は,`CameraBase` の校正停止フラグでフレームコールバックの校正処理(チャート検出・ホワイトバランスの調整・変換行列の算出)を止める(カメラの前にチャートをはめたまま検査するため). +- `SELFTEST` の部品(`SelfTestConfig`・`SelfTestReport`・`SelfTestPump`・閉じる処理の段階の記録)を使うため,`SELFTEST` と同時に定義する必要がある(`HwTest/HwTestRunner.cs`・`UI/Form1.HwTest.cs` が `#error` で検出する). +- 機材を操作するため,**ユーザーの合図があるときだけ実行する**.実行手順は [ENV_04_開発コマンド](ENV_04_開発コマンド.md) と `.claude/verify-profile.md` を参照する. +- 配布版には不要なため Release では未定義(Release の exe に `--hwtest` を渡しても通常起動になる). + ## ビルド方法 (How to Build) -標準構成(Debug / Release)のビルド・テスト・実行コマンドは [ENV_04_開発コマンド](ENV_04_開発コマンド.md) を参照する.`Debug|x64` では `WHITEBOARD`・`MEMMONITOR`・`SELFTEST` が自動で有効になり(メモリ観測ウィンドウが開き,白板撮影ボタンが表示され,`--selftest` 引数が使える),`Release|x64` ではいずれの診断フラグも含まれない. +標準構成(Debug / Release)のビルド・テスト・実行コマンドは [ENV_04_開発コマンド](ENV_04_開発コマンド.md) を参照する.`Debug|x64` では `WHITEBOARD`・`MEMMONITOR`・`SELFTEST`・`HWTEST` が自動で有効になり(メモリ観測ウィンドウが開き,白板撮影ボタンが表示され,`--selftest`・`--hwtest` 引数が使える),`Release|x64` ではいずれの診断フラグも含まれない. ## 関連 (Related) diff --git "a/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" "b/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" index c97b203..7f14c81 100644 --- "a/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" +++ "b/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" @@ -111,7 +111,7 @@ | --- | --- | | 校正中の例外(`CalcTcc`・`ProcessCalibrationFrame`) | カメラのコールバックへ例外を伝えない.捕捉してログを残し,「校正に失敗しました」と通知し,チャート検出からやり直せる状態(未校正・マスク解放)に戻す.通知がチャート検出の進捗表示ですぐ上書きされないよう,次に校正が完了するまで検出中の表示を「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替えて出し続ける(エラーダイアログはカメラのコールバックを止めるため使わない) | | 校正画像(TccImage・TccRoisImage)の保存失敗 | 変換行列はメモリ上で有効なので校正済みのままにし,「自動校正完了(校正画像の保存に失敗)」のように警告を表示する | -| アプリを閉じるとき(`Form1_FormClosing`) | 保存中なら閉じるのを取り消して「保存中」と知らせる(閉じる理由は区別しない.Windows のシャットダウン時も取り消し,待つか強制終了するかはユーザーに選ばせる).閉じ始めたらカメラのコールバックから UI への呼び出しを行わない(ハングの防止).光源を消してポートを閉じる | +| アプリを閉じるとき(`Form1_FormClosing`) | 保存中なら閉じるのを取り消して「保存中」と知らせる(閉じる理由は区別しない.Windows のシャットダウン時も取り消し,待つか強制終了するかはユーザーに選ばせる).閉じ始めたらカメラのコールバックから UI への呼び出しを行わない(ハングの防止).光源のポートを閉じる(**消灯はしない**.終了後も光源は点いたまま.上流 v1.7 と同じ動作で,2026-10-08 にユーザーが仕様として確定) | | 保存スレッドから UI への通知 | 進捗表示などは UI スレッドを待たない非同期の呼び出しにする(撮影中でも保存が進み,画像がキューに溜まり続けない) | | 保存キューの待ち時間(`DrainShots`) | 固定 30 秒をやめ,「30 秒」と「撮影間隔の 2 倍」の大きい方にする | @@ -119,7 +119,7 @@ - 上表の方針をすべて実装した.判断(閉じてよいか,待ち時間,通知文の組み立て)は `Form1.CanClose`・`CameraBase.GetDrainIdleTimeout`・`FormatCalibrationFailure`・`ShouldNotifyCalibrationFailure`・`FormatChartDetectionMessage`・`FormatCalibrationDone` として切り出し,層①でテストした - 層②の新規・書き換えテストは修正前のコードで 14 件が赤になることを確認した.層②は計 36 件で 1 回あたり約 21〜26 秒(目安 30 秒に近づいてきたので,次に層②を増やすときはテスト用設定の見直しを先に検討する) - 保存中の切断テストは,通知の非同期化で「保存スレッドが UI を待つ間は必ず生きている」前提が崩れたため,保存スレッドを意図的に止めておく形に書き直した - - 実機でしか確かめられない残り: 保存中・プレビュー中・校正中に閉じたときの挙動(SDK のストリーム停止との待ち合い),光源の消灯とポートの解放,撮影と保存が並行するようになった後の連写間隔の精度 + - 実機でしか確かめられない残り: 保存中・プレビュー中・校正中に閉じたときの挙動(SDK のストリーム停止との待ち合い),光源のポートの解放(終了後も点灯したままであること),撮影と保存が並行するようになった後の連写間隔の精度 - **3b(未着手)**: ROI とフレームサイズの照合,フレームバッファのサイズ検査,光源ポートを開けなかったときの扱い(`/auto-audit` 報告台帳の該当項目) ### フェーズ 4: 実機録画データによる回帰 diff --git "a/docs/03_PLAN/PLAN_06_\345\256\237\346\251\237\350\207\252\345\213\225\343\203\206\343\202\271\343\203\210\350\250\210\347\224\273.md" "b/docs/03_PLAN/PLAN_06_\345\256\237\346\251\237\350\207\252\345\213\225\343\203\206\343\202\271\343\203\210\350\250\210\347\224\273.md" index 73d2b00..75496d2 100644 --- "a/docs/03_PLAN/PLAN_06_\345\256\237\346\251\237\350\207\252\345\213\225\343\203\206\343\202\271\343\203\210\350\250\210\347\224\273.md" +++ "b/docs/03_PLAN/PLAN_06_\345\256\237\346\251\237\350\207\252\345\213\225\343\203\206\343\202\271\343\203\210\350\250\210\347\224\273.md" @@ -29,24 +29,33 @@ - 保存先は一時フォルダ(`%TEMP%` 配下)にする.本番の保存先(`C:\TIAS_Data` 等)には書き込まない - 同梱の `config.xml` は変更しない(一時フォルダに複製して保存先等を差し替える) - カメラのデバイス状態ファイル(`{DeviceName}.xml`)は読むだけで,上書きしない.露光・ゲイン等を検査で変えた場合は,終了時に元の値へ戻す -- 光源は検査の最後に必ず消灯してポートを閉じる(失敗した場合も) + - ImagingSource の露光・ゲインは絶対値(`VCDAbsoluteValueProperty`.露光は秒,ゲインは dB)で読み書きし,元の絶対値そのものへ戻して読み戻しで確かめる(整数の段(`VCDRangeProperty`)で扱うと元の値に戻せないため) +- チャート不要の検査の間は自動校正(チャート検出・ホワイトバランスの調整・変換行列の算出)を止める(`CameraBase` の校正停止フラグ.`HWTEST` ビルド限定).はめたチャートで校正が走ると露光・ゲイン・ホワイトバランスが検査中に変わるため +- 光源は検査の最後に必ずポートを閉じる(失敗した場合も).消灯はしない(アプリ終了時に光源を消さない仕様に合わせる.`light` 項目の最後に再点灯した状態で終わる) - 待機には必ずタイムアウトを付ける ## 検査項目 (Test Items) ### チャート不要(`--with-chart` なし) +※ 穴に何もはめないと中が暗く明るさの変化を評価しにくいため,**カラーチャート(または白板)をはめた状態で行う**.この検査の間は,チャートが写っても自動校正を走らせない(テストモードだけの処理.2026-10-08 にユーザーと合意). + | ID | 確認内容 | 判定の仕方 | | --- | --- | --- | | `connect` | 指定した機種に接続でき,機種名・シリアル番号が取れる | `Connect()` の成否 | | `preview` | プレビューが流れる | 一定時間に届いたフレーム数(フレームレートの下限) | -| `exposure-gain` | 露光・ゲインの設定がフレームに効く | 設定を上げ下げしたときのフレームの平均輝度の変化の向き | +| `exposure-gain` | 露光・ゲインの設定がフレームに効く | 基準の明るさを探してから,露光を 1 段(2^n 秒の段で半分・2 倍),ゲインを範囲の幅の ±10% 上げ下げしたときの平均輝度の変化の向きと量.飽和した向きは確認しない.終了時に元の値へ戻ったことを読み戻しで確かめる | | `light` | 光源が点灯・消灯し,閉じた後に開き直せる | 点灯時と消灯時のフレームの平均輝度の差,ポートの再オープンの成否 | -| `close-preview` | プレビュー中に閉じる処理を呼んでもすぐ戻る | 後始末の所要時間(上限つき)と例外の有無 | +| `close-preview` | プレビュー中に閉じる処理を呼んでもすぐ戻る | 後始末の所要時間(上限つき)と例外の有無,閉じた後に届いたフレーム数 | | `reconnect` | 切断した後に再接続できる | 2 回目の `Connect()` の成否とフレームの到着 | +| `cleanup` | 後始末と書き込み禁止 | 露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯しない)の成否,設定・デバイス状態ファイル・本番の保存先が変わっていないこと | + +露光・ゲインの基準探し: 元の露光に最も近い 2^n 秒の段に合わせ,平均輝度が中間(30〜220)かつ飽和画素(254 以上)の割合が 1% 以下になるまで露光を 1 段ずつ動かす(最大 12 段).実測で露光 1 段は 0.497 倍(黒レベル 15.2 を引いた値)≈ 0.5 倍,ゲイン 1 dB は 1.122 倍 ≈ 10^(1/20) と理論どおりに効いた. ### チャートが必要(`--with-chart`.人間がチャートをカメラの前に置いてから実行する) +※ チャート不要の検査と同じくカラーチャートをはめたまま続けて実行できる(付け外しは不要).こちらは自動校正を走らせて結果を確かめる. + | ID | 確認内容 | 判定の仕方 | | --- | --- | --- | | `calibrate` | 実物の舌診チャートで自動校正が完了する | 校正完了までの時間(上限つき)と,変換後の色票と参照値の色差 | @@ -54,11 +63,27 @@ | `close-saving` | 保存中に閉じようとすると取り消され,保存後は閉じられる | 取り消しの成否と表示文,保存完了後の後始末の順序 | | `close-calibrating` | 校正中に閉じてもすぐ戻る | 後始末の所要時間と例外の有無 | -※ 判定のしきい値(フレームレートの下限,色差の上限,撮影間隔の許容誤差等)は,最初の実行で実測値を見てから決める. +※ 判定のしきい値(色差の上限,撮影間隔の許容誤差等)は,最初の実行で実測値を見てから決める.チャート不要の検査のしきい値はフェーズ 1 で決めた(下記「確定したしきい値と実測値」). + +### 確定したしきい値と実測値 (Thresholds and Measurements) + +フェーズ 1 の実測(ImagingSource DFK 33UX178.シリアル 35620307・10620446.カラーチャートをはめた状態)から決めた.しきい値の本体は `HwTest/HwTestJudge.cs`. + +| 項目 | しきい値 | 実測値 | +| --- | --- | --- | +| `preview` のフレームレート | 10 fps 以上 | 13〜18 fps | +| `exposure-gain` の露光 | 1 段下げで比 0.8 以下,1 段上げで比 1.25 以上(向きごと.飽和した向きは確認しない) | 下げ 0.545(2026-10-08 13:38 の実行.上げは比 1.322 だが飽和画素 70% のため確認しない向き) | +| `exposure-gain` のゲイン | 平均輝度の差 3 以上(片側の向きだけ期待どおりでも合格) | 下げ −15.6(同上.上げは +47.1 だが飽和画素 68% のため確認しない向き) | +| 基準の明るさ | 平均輝度 30〜220,飽和画素(254 以上)の割合 1% 以下 | 元の露光に近い段(0.015625 秒)で 158.9 | +| 飽和の判定 | 254 以上の画素の割合が 1% を超える | — | +| `light` の点灯と消灯の差 | 平均輝度の差 100 以上 | 153〜196 | +| `close-preview` の閉じる処理 | 1000 ms 以内,閉じた後に届くフレーム 2 以下 | 75〜221 ms,0 フレーム | +| `reconnect` | 再接続後に 5 フレーム以上 | 964 ms で 5 フレーム(2026-10-08 13:38 の実行) | +| 露光・ゲインの復元 | 元の値との相対誤差 1% 以内 | 一致 | ## 進め方 (Workflow) -1. 人間が機材をつなぎ(チャートが要る検査ではチャートを置き),「実機テストして」等と合図する +1. 人間が機材をつなぎ,カラーチャート(または白板)を穴にはめて,「実機テストして」等と合図する 2. Claude(verifier)が `python scripts/verify/run_hwtest.py --camera <機種> [--with-chart]` を実行し,結果ファイルと終了コードで判定する 3. 2 機種とも行う場合は,機種ごとに実行する 4. 人間に残る確認は,画面の見た目の最終確認など,数値で置き換えられないものだけにする @@ -82,6 +107,9 @@ - チャート不要の検査(`connect`・`preview`・`exposure-gain`・`light`・`close-preview`・`reconnect`)を実装する - 検証プロファイルを更新する - 完了条件: 両機種で人間の合図のもと実行でき,しきい値を実測から決めて全項目が合格する +- **状態: ImagingSource で完了(2026-10-08.DFK 33UX178 の 2 個体で 7/7 合格).Lumenera は機材がつながり次第実行する** +- 実機テストで見つかった不具合: 同梱の `DFK33UX178.xml` は別個体(シリアル 41520093)のものを指しており,旧 `IScam.Connect` はファイルのデバイスを開いていたため,別個体では接続できず例外が外へ漏れていた.機種名でデバイスを開いてから設定だけを適用し,失敗は `ErrorMsg`+`false` で返すように直した(`Cameras/IScamDeviceSelector`.[TECH_02](../05_TECH/TECH_02_カメラ制御仕様.md)) +- 仕様変更: アプリ終了時に光源を消灯しない(ポートを閉じるだけ.上流 v1.7 と同じ.2026-10-08 にユーザーが確定).実機テストの後始末も同じく消灯しない ### フェーズ 2: チャートが必要な検査 @@ -103,6 +131,6 @@ | --- | --- | | 撮影の本番中に Claude が機材を操作してしまう | 実行はユーザーの合図があったときだけ.検証プロファイルで明記し,合図がなければ検証不能として報告する | | 検査でカメラの設定が変わったまま残る | デバイス状態ファイルは上書きしない.変えた露光・ゲインは終了時に元に戻す | -| 光源が点いたまま残る | 検査の最後(失敗時も)に消灯してポートを閉じる | +| 光源のポートが開いたまま残る | 検査の最後(失敗時も)にポートを閉じる.光源は点いたままでよい(アプリ終了時と同じ仕様) | | 実機の個体差・照明で判定が不安定になる | しきい値は実測から余裕を持って決める.輝度は絶対値ではなく変化の向きで判定する | | SDK がハングして検査が終わらない | 検査全体と各項目にタイムアウトを付け,ランナー側でもプロセスを打ち切る | diff --git "a/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" index 5fe9e52..9b91ddc 100644 --- "a/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" @@ -104,3 +104,5 @@ - 対応デバイス: Silicon Labs CP210x(`config.xml` で変更可能). - キャリブレーション中は光源の切り替えが無効になる. - 光源デバイスが見つからない場合,光源ボタンが無効化される. +- 起動時にポートを開いて点灯する. +- アプリ終了時は消灯せず,ポートを閉じるだけにする(終了後も光源は点いたまま残る).上流 v1.7 と同じ動作で,2026-10-08 にユーザーが仕様として確定した. diff --git "a/docs/05_TECH/TECH_02_\343\202\253\343\203\241\343\203\251\345\210\266\345\276\241\344\273\225\346\247\230.md" "b/docs/05_TECH/TECH_02_\343\202\253\343\203\241\343\203\251\345\210\266\345\276\241\344\273\225\346\247\230.md" index adec6b7..94e7105 100644 --- "a/docs/05_TECH/TECH_02_\343\202\253\343\203\241\343\203\251\345\210\266\345\276\241\344\273\225\346\247\230.md" +++ "b/docs/05_TECH/TECH_02_\343\202\253\343\203\241\343\203\251\345\210\266\345\276\241\344\273\225\346\247\230.md" @@ -19,7 +19,10 @@ ### 接続 -- `[デバイス名].xml` からカメラ設定をロードする. +- 列挙したデバイスから,機種名(列挙名から空白を除いたもの.例: `DFK 33UX178` → `DFK33UX178`)が一致するものを選んで開く(`IScamDeviceSelector`). +- `[デバイス名].xml`(デバイス状態ファイル)は設定の適用だけに使う(`LoadDeviceStateFromFile(file, false)`).ファイルに書かれた個体(シリアル番号)は開かないため,同じ機種なら別の個体でも接続できる. + - ※ 以前は `LoadDeviceStateFromFile(file, true)` でファイルのデバイスを開いていたため,同梱の `DFK33UX178.xml`(シリアル 41520093 の個体)と別の個体では接続できず,例外が外へ漏れていた(2026-10-08 に実機テストで発見して修正). +- デバイスが見つからない・開けない・設定を適用できない場合は,例外を外へ出さず `ErrorMsg` に理由を設定して `false` を返す(起動時の接続フローが次のデバイスを試せる). - `config.xml` の `<カメラ名>/ROI` から ROI 設定を読み込む. - `FrameQueueSink` をシンクに設定してライブ開始する. diff --git "a/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" "b/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" index 8062629..2084455 100644 --- "a/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" +++ "b/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" @@ -10,6 +10,7 @@ - 結合ロジックテスト: 偽カメラとヘッドレス Form1 で,撮影・保存の流れと失敗時の通知を NUnit で自動確認する(`[Category("Integration")]`.方針は `.claude/rules/testing.md`,移行計画は [PLAN_05](../03_PLAN/PLAN_05_テスト網羅性向上計画.md)) - 回帰テスト: リファクタリング前後の出力が一致することを確認する - セルフテスト: 実機なしで依存ロード・プレビュー経路・校正〜保存を自動確認する(手動テストの前段) +- 実機テスト: Claude が実機のカメラと光源を操作して,接続・プレビュー・露光/ゲイン・光源・閉じる処理・再接続を自動確認する(ユーザーの合図があるときだけ.手動テストの前段) - 手動テスト: 実機を使って動作を目視確認する ## セルフテスト (Self Test) @@ -18,7 +19,28 @@ - `python scripts/verify/run_selftest.py`(依存を変えた場合は `--full`) - **合格条件**: 終了コード 0(`deps`・`preview`・`calib-save` の 3 項目が PASS) -- 代替していない範囲(実カメラ SDK との結合・実プレビュー表示・実チャートの検出性能・画像の見た目・長時間のメモリ/GDI)は,引き続き下記の手動テストで確認する +- 代替していない範囲(実カメラ SDK との結合・実プレビュー表示・実チャートの検出性能・画像の見た目・長時間のメモリ/GDI)は,下記の実機テストと手動テストで確認する + +## 実機テスト (Hardware Test) + +Claude が実機のカメラと光源を操作して,層④のうち数値で判定できる項目を自動で確認する([PLAN_06](../03_PLAN/PLAN_06_実機自動テスト計画.md).`Debug|x64` のみ.手順は [ENV_04](../02_ENV/ENV_04_開発コマンド.md) の「実機テスト」,判定としきい値の詳細は `.claude/verify-profile.md` の「実機テスト」節).**ユーザーの合図があるときだけ実行する**(撮影の本番中に機材を操作しないため). + +- `python scripts/verify/run_hwtest.py --camera `(2 機種とも行う場合は機種ごとに実行する) +- **実行の前提**: カメラ・光源のドライバが入っている([ENV_02](../02_ENV/ENV_02_環境構築手順.md)),検査する機種のカメラと光源が接続されている,カメラの前の穴にカラーチャート(または白板)がはめてある,TIASshot が起動していない +- **合格条件**: 終了コード 0(下表の 7 項目が PASS.結果は `.verify/hwtest/run-<日時>/hwtest-result.txt`) + +| 項目 | 確認内容 | 合格条件 | +| --- | --- | --- | +| `connect` | 指定した機種に接続でき,機種名・シリアル番号が取れる | `Connect()` が成功する | +| `preview` | プレビューが流れる | 10 fps 以上で,本モニタに表示用 Bitmap がある | +| `exposure-gain` | 露光・ゲインの設定がフレームに効く | 基準(平均輝度 30〜220・飽和画素 1% 以下)から露光 1 段下げで比 0.8 以下・上げで比 1.25 以上(飽和した向きは確認しない),ゲインは差 3 以上(片側でよい).元の値に戻る | +| `light` | 光源が点灯・消灯し,閉じた後に開き直せる | 点灯と消灯の平均輝度の差が 100 以上.ポートを開き直した後も点灯が効く | +| `close-preview` | プレビュー中に閉じる処理を呼んでもすぐ戻る | 1000 ms 以内に戻り,例外がなく,閉じた後に届くフレームが 2 以下 | +| `reconnect` | 切断した後に再接続できる | 2 回目の `Connect()` が成功し,5 フレーム以上届く | +| `cleanup` | 後始末と書き込み禁止 | 露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯しない)が成功し,設定・デバイス状態ファイル・本番の保存先が変わっていない | + +- 2026-10-08 に ImagingSource(DFK 33UX178 の 2 個体)で 7/7 合格した.Lumenera は未実施 +- チャートが要る項目(校正・撮影・保存中や校正中に閉じる.PLAN_06 フェーズ 2)は未実装のため,下記の手動テストで確認する ## 結合ロジックテスト (Integration Logic Tests) @@ -109,13 +131,17 @@ - アプリが正常に起動する - カメラが自動で接続される(Lucam または ImagingSource) + - ※ 指定した機種への接続そのものは実機テスト(`connect`)でも確認できる.自動で機種を選んで接続する流れは手動で確認する - プレビューが表示される + - ※ フレームが流れることは実機テスト(`preview`)でも確認できる.見た目は手動で確認する - 舌診チャートを映すとキャリブレーションが自動開始される - キャリブレーション完了後に撮影ボタンが有効になる - 1枚撮影が正常に完了し,ファイルが保存される - 連続撮影が正常に完了し,ファイルが保存される - 光源の ON/OFF が切り替わる -- アプリを閉じてもエラーが出ない + - ※ 実機テスト(`light`)でも確認できる +- アプリを閉じてもエラーが出ない(閉じた後も光源は点いたまま残るのが仕様) + - ※ プレビュー中に閉じる場合は実機テスト(`close-preview`)でも確認できる **合格条件** diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5ea2c2f..4c6da3d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -190,3 +190,10 @@ - [x] **実機自動テストの計画(PLAN_06)を策定** (2026-10-06): 層④(SDK の実呼び出し・実画像)に残る手動確認を減らすため,`--hwtest` で Claude が開発 PC につないだ実機のカメラ・光源を操作して検査する計画を立てた.実行はユーザーの合図があったときだけとし,保存先は一時フォルダ,設定ファイルは書き換えない. - 設計判断: ImagingSource は SDK の画面部品を使うため,ヘッドレスではなく実際の `Form1` を表示して動かす.両機種を同時につないでも `--camera` で 1 台ずつ検査する(通常起動は Lumenera を優先するため). + +- [x] **実機自動テスト(PLAN_06 フェーズ 1)を実装し,ImagingSource の実機で 7 項目が合格** (2026-10-08): `TIASshot.exe --hwtest`(`HWTEST` フラグ,Debug のみ)とランナー `run_hwtest.py` を作り,Claude が開発 PC につないだカメラと光源を操作して接続・プレビュー・露光/ゲイン・光源・閉じる処理・再接続・後始末を検査できるようにした.DFK 33UX178 の 2 個体で 7/7 合格し,しきい値を実測から決めた(PLAN_06).開発 PC に実機用のドライバ 3 種(LuCam Software・Cam33U Driver・CP210x)を入れ,ENV_02 に入手先を記録した. + - 動機: 層④に残る手動確認のうち,数値で判定できる SDK の実呼び出しを人間の手から外すため(PLAN_05 の 4 層モデルの仕上げ). + - 設計判断: チャート不要の検査も穴にカラーチャート(または白板)をはめて行い,その間は自動校正を止める.穴が空だと暗くて明るさの変化を評価しにくく,一方でチャートが写ると校正が走って検査中に露光・ゲイン・ホワイトバランスが変わるため.露光・ゲインは整数の段ではなく絶対値(秒・dB)で扱い,2^n 秒の段で動かして元の絶対値へ戻す.実測で露光 1 段は約 0.5 倍,ゲイン 1 dB は約 1.122 倍と理論どおりに効いた. + - 仕様変更: アプリ終了時に光源を消灯しない(ポートを閉じるだけ.上流 v1.7 と同じ)とユーザーが確定した. + - 失敗パターン: ① 同梱の `DFK33UX178.xml` は別個体のシリアルを持ち,旧 `IScam.Connect` がファイルのデバイスを開いていたため,手元の個体では接続できず例外が外へ漏れた.機種名でデバイスを開いてから設定だけ適用する形に直した(本番の接続にも効く修正).② 露光を整数の段(`VCDRangeProperty`)で扱っていたため,2^n でない元の露光(1/60 秒等)に戻せていなかった.絶対値で扱う形に改めた.③ Release の exe を作り直さずに確認し,古い動作を見て判断を誤りかけた.確認は必ずランナー(Debug をビルドし直す)経由で行う. + - 後段: Lumenera は機材が未接続で未実施(つながり次第実行する).フェーズ 2(チャートが要る検査).本番の Info.csv の露光記録が整数の段を「1/10 して ms」として書いており誤った値になっている疑い(未確認.報告台帳に記録). diff --git a/scripts/verify/run_hwtest.py b/scripts/verify/run_hwtest.py new file mode 100644 index 0000000..c5c279f --- /dev/null +++ b/scripts/verify/run_hwtest.py @@ -0,0 +1,140 @@ +"""TIASshot の実機テストのランナー(PLAN_06.ユーザーが会話の中で実機テストを合図したときだけ実行する). + +実機のカメラ(Lumenera / ImagingSource)と光源(シリアルポート)を操作する.撮影の本番中に実行しないこと. +NUnit テスト・セルフテストは実行しない(それぞれ run_tests.py・run_selftest.py). + +手順: + 1. ランナーがビルドする構成(Debug|x64)の出力 bin/x64/Debug・obj/x64/Debug を削除する(--no-clean で省略) + 2. Debug|x64 をビルドする(HWTEST フラグ入り) + 3. TIASshot.exe が起動していないことを確かめる(起動中はカメラ・光源を使用中=撮影の本番中の可能性があるため実行しない) + 4. TIASshot.exe --hwtest --camera <機種> を実行ファイルのフォルダで実行し,終了コードと結果ファイルで判定する + (実際のフォームが表示される.保存先は %TEMP%\\TIASshot-hwtest\\work-* に差し替えられる) + +出力: .verify/hwtest/run-<日時>/ に build.log・hwtest-result.txt・summary.json +終了コード: 0=全項目合格,1=不合格あり(ビルド失敗・検査の不合格・タイムアウト), + 2=前提条件を満たさない(ツール未導入・TIASshot 起動中・指定機種が見つからない・引数不正・--with-chart(未実装)) + +使い方(リポジトリルートで実行): + python scripts/verify/run_hwtest.py --camera lucam + python scripts/verify/run_hwtest.py --camera iscam + python scripts/verify/run_hwtest.py --camera iscam --no-clean # Debug 出力を消さない(開発時の短縮用) +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import subprocess +import sys + +from run_selftest import (BUILD_TIMEOUT_SEC, EXE, EXIT_FAIL, EXIT_OK, EXIT_PRECONDITION, REPO, SLN, + PreconditionError, clean_build_outputs, find_vs, run_logged) + +# 実機テスト内部のウォッチドッグ(全体 10 分)より長くする.超えたらランナーがプロセスを打ち切る +HWTEST_TIMEOUT_SEC = 720 + +# 実機テスト(TIASshot.exe --hwtest)の終了コード +HW_PASS = 0 +HW_FAIL = 1 +HW_ERROR = 2 +HW_TIMEOUT = 3 + + +def is_tiasshot_running() -> bool: + """TIASshot.exe が起動しているか(人間が撮影中ならカメラ・光源を使用中のため実機テストを行わない).""" + proc = subprocess.run(["tasklist", "/FI", "IMAGENAME eq TIASshot.exe", "/FO", "CSV", "/NH"], + capture_output=True, check=False) + out = proc.stdout.decode("cp932", errors="replace") + return "tiasshot.exe" in out.lower() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--camera", required=True, choices=["lucam", "iscam"], help="検査するカメラの機種") + parser.add_argument("--with-chart", action="store_true", + help="チャートが必要な検査も行う(PLAN_06 フェーズ 2.未実装のため終了コード 2 になる)") + parser.add_argument("--no-clean", action="store_true", + help="Debug 出力(bin/obj の x64/Debug)を削除しない(開発時の短縮用)") + args = parser.parse_args() + # エージェント(Git Bash 経由)が読めるよう,本ランナーの出力は UTF-8 に固定する + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + run_dir = REPO / ".verify" / "hwtest" / f"run-{stamp}" + run_dir.mkdir(parents=True, exist_ok=True) + summary: dict = {"run_dir": str(run_dir), "camera": args.camera, "with_chart": args.with_chart, + "steps": [], "result": None} + + def step(name: str, ok: bool, detail: str) -> None: + summary["steps"].append({"name": name, "ok": ok, "detail": detail}) + print(f"[{'OK' if ok else 'NG'}] {name}: {detail}", flush=True) + + exit_code = EXIT_OK + try: + vs = find_vs() + msbuild = vs / "MSBuild" / "Current" / "Bin" / "MSBuild.exe" + if not msbuild.exists(): + raise PreconditionError(f"MSBuild.exe が見つかりません: {msbuild}") + + if is_tiasshot_running(): + raise PreconditionError("TIASshot.exe が起動しています(撮影の本番中の可能性.カメラ・光源を使用中のため実機テストを行いません)") + + if not args.no_clean: + clean_build_outputs() + step("clean", True, "Debug 出力(bin/obj の x64/Debug)を削除") + + code = run_logged([str(msbuild), str(SLN), "/t:Build", "/p:Configuration=Debug", "/p:Platform=x64", + "/nologo", "/v:minimal"], run_dir / "build.log", BUILD_TIMEOUT_SEC) + step("build", code == 0, f"Debug|x64 ビルド exit {code}(build.log)") + if code != 0: + exit_code = EXIT_FAIL + raise RuntimeError("ビルド失敗") + + result_file = run_dir / "hwtest-result.txt" + cmd = [str(EXE), "--hwtest", "--camera", args.camera, "--hwtest-out", str(result_file)] + if args.with_chart: + cmd.append("--with-chart") + summary["command"] = " ".join(cmd) + try: + # デバイス状態ファイル({DeviceName}.xml)は相対パスで読まれるため,通常起動と同じく実行ファイルのフォルダで実行する + proc = subprocess.run(cmd, cwd=EXE.parent, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=HWTEST_TIMEOUT_SEC, check=False) + code = proc.returncode + except subprocess.TimeoutExpired: + code = None + text = result_file.read_text(encoding="utf-8-sig", errors="replace") if result_file.exists() else "" + heads = [ln for ln in text.splitlines() if ln.startswith("[") or ln.startswith("RESULT:")] + summary["hwtest_exit_code"] = code + summary["hwtest_lines"] = heads + if code is None: + detail = f"{HWTEST_TIMEOUT_SEC} 秒で終わらないためランナーが打ち切りました(hwtest-result.txt)" + else: + detail = f"exit {code}(hwtest-result.txt)" + step("hwtest", code == HW_PASS, detail) + for ln in heads: + print(f" {ln}", flush=True) + if code == HW_ERROR: + # 指定機種が見つからない・引数不正・未実装の指定 → 実機テスト自体を行えない(検証不能) + exit_code = EXIT_PRECONDITION + elif code != HW_PASS: + exit_code = EXIT_FAIL + except PreconditionError as ex: + step("precondition", False, str(ex)) + exit_code = EXIT_PRECONDITION + except RuntimeError as ex: + step("abort", False, str(ex)) + exit_code = EXIT_FAIL + except subprocess.TimeoutExpired as ex: + step("timeout", False, f"{ex.cmd} が {ex.timeout} 秒で終わりません") + exit_code = EXIT_FAIL + + summary["result"] = {EXIT_OK: "PASS", EXIT_FAIL: "FAIL", EXIT_PRECONDITION: "PRECONDITION"}[exit_code] + (run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"RESULT: {summary['result']} (出力: {run_dir})", flush=True) + return exit_code + + +if __name__ == "__main__": + sys.exit(main())