diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 2fbc968..3d02521 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -56,7 +56,7 @@ | ビルドテスト | ソリューション全体 | 自動(MSBuild) | 全コミット前 | | 回帰テスト | 色補正・チャート検出の出力(合成チャート,および実機で録画したチャート・白板のフレーム) | 自動(基準データ比較) | リファクタ・修正の前後 | | セルフテスト | 依存ロード・偽カメラでのプレビュー経路・合成チャートでの校正〜保存 | 自動(`--selftest`.`/verify`・`/implement` Phase 1b) | 依存更新・層②〜④の変更後,手動テストの前段 | -| 実機テスト | 層④のうち数値で判定できる SDK の実呼び出し(接続・プレビュー・露光/ゲイン・光源・閉じる・再接続) | 自動(`--hwtest`.Claude が実機を操作する.**ユーザーの合図があるときだけ**) | 層③・④の変更後,手動テストの前段 | +| 実機テスト | 層④のうち数値で判定できる SDK の実呼び出し(接続・プレビュー・露光/ゲイン・光源・閉じる・再接続.`--with-chart` では実チャートでの校正・撮影と保存・保存中/校正中に閉じる) | 自動(`--hwtest`.Claude が実機を操作する.**ユーザーの合図があるときだけ**) | 層③・④の変更後,手動テストの前段 | | 手動テスト | SDK の実呼び出し・実画像の見た目(層④) | 手動(実機) | 機能追加・修正の完了時 | ※ 実機録画データ(舌診チャート・白板のフレーム)による回帰は PLAN_05 のフェーズ 4 で整備する.**舌の画像は個人情報のためテストデータに含めない**. @@ -96,7 +96,7 @@ | `LightSource` のシリアル通信 | 光源(実機)に依存する.ポートを開けなかったときの扱い等の判断は層③として切り出す | | 画面描画の見た目(`Form1`・`PreviewMonitor` の表示内容) | 目視でしか判断できない.表示用 Bitmap の所有権・更新の流れはセルフテスト(`preview`)で担保する | -層④のうち次の項目は,実機テスト([PLAN_06](../../docs/03_PLAN/PLAN_06_実機自動テスト計画.md).`python scripts/verify/run_hwtest.py --camera `.`HWTEST` フラグ)で Claude が実機を操作して自動で確認できる.**ユーザーが会話の中で合図したときだけ**実行し,合図が無ければ「検証不能(合図なし)」とする(手順・しきい値は `.claude/verify-profile.md` の「実機テスト」節). +層④のうち次の項目は,実機テスト([PLAN_06](../../docs/03_PLAN/PLAN_06_実機自動テスト計画.md).`python scripts/verify/run_hwtest.py --camera [--with-chart]`.`HWTEST` フラグ)で Claude が実機を操作して自動で確認できる.**ユーザーが会話の中で合図したときだけ**実行し,合図が無ければ「検証不能(合図なし)」とする(手順・しきい値は `.claude/verify-profile.md` の「実機テスト」節). | 項目 | 確認できること | | --- | --- | @@ -106,8 +106,12 @@ | `light` | 光源の点灯・消灯が明るさに効き,ポートを閉じた後に開き直せる | | `close-preview` | プレビュー中に閉じる処理を呼んでもすぐ戻り,後始末の順序が正しい | | `reconnect` | 切断した後に再接続でき,フレームが届く | +| `calibrate`(`--with-chart`) | 実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値に近い(8bit・チャネルごとの差) | +| `shot`(`--with-chart`) | 単発・連続撮影で撮影画像・sRGB 変換画像・Info.csv が保存され,撮影時刻の間隔が設定どおり | +| `close-saving`(`--with-chart`) | 保存中に閉じようとすると取り消され,保存の完了後は閉じられる(後始末の順序) | +| `close-calibrating`(`--with-chart`) | 再接続して校正中に閉じる処理を呼んでもすぐ戻る | -※ 2026-10-08 時点で実機テストを通したのは ImagingSource だけ(Lumenera は未実施).校正・撮影・保存中や校正中に閉じる等のチャートが要る項目(PLAN_06 フェーズ 2)は未実装のため,引き続き手動テストで確認する. +※ 2026-10-08 時点で実機テストを通したのは ImagingSource だけ(Lumenera の実機テストは行わない方針.PLAN_06「対象機種」).チャートが要る項目(`--with-chart`.PLAN_06 フェーズ 2)も同日に実物のチャートで合格した.`--with-chart` はチャート不要の検査を行わないため,両方を続けて実行する.変換画像の見た目(17 次元の変換の点状のノイズ等.PLAN_06「17 次元の変換のノイズ」)は数値で判定しきれないため,引き続き手動で確認する. ※ 層④に手を入れた場合は**設計レビュー+実機回帰**で挙動を確認する.その前段として,実機なしで通せる範囲はセルフテスト(`python scripts/verify/run_selftest.py`.`.claude/verify-profile.md`)で,上表の実機テストの項目はユーザーの合図のもと実機テストで確認できる.`/implement` の Tester フェーズでは,対象が層④だけの場合に限り,テストを書かずに設計レビューで判断してよい.層②・③に当たる部分が含まれるなら,その部分のテストは書く. diff --git a/.claude/verify-profile.md b/.claude/verify-profile.md index 00579ad..b5ddbe5 100644 --- a/.claude/verify-profile.md +++ b/.claude/verify-profile.md @@ -57,22 +57,27 @@ ### 実行コマンド -リポジトリルートで実行する.2 機種とも検査する場合は機種ごとに実行する. +リポジトリルートで実行する.**対象は ImagingSource だけ**で,Lumenera の実機テストは行わない(PLAN_06.Lumenera は解像度が相対的に低く今後あまり使わない見込みのため).ユーザーが明示的に Lumenera の検査を指示した場合に限り `--camera lucam` を使う. | 用途 | コマンド | | --- | --- | -| Lumenera を検査する | `python scripts/verify/run_hwtest.py --camera lucam` | -| ImagingSource を検査する | `python scripts/verify/run_hwtest.py --camera iscam` | +| Lumenera を検査する(通常は行わない.ユーザーの明示指示があるときだけ) | `python scripts/verify/run_hwtest.py --camera lucam` | +| ImagingSource を検査する(チャート不要の検査) | `python scripts/verify/run_hwtest.py --camera iscam` | +| ImagingSource を検査する(チャートが必要な検査.上の行に続けて実行する) | `python scripts/verify/run_hwtest.py --camera iscam --with-chart` | -- `--with-chart`(チャートが必要な検査.PLAN_06 フェーズ 2)は未実装.指定すると機材に触れずに終了コード 2 になるため使わない +- `--with-chart`(チャートが必要な検査.PLAN_06 フェーズ 2)を指定すると,チャート不要の検査(`preview`〜`reconnect`)の代わりに `calibrate`・`shot`・`close-saving`・`close-calibrating` を行う(`connect`・`cleanup` は両方で行う).校正は止めない.**チャート不要の検査は行わないため,`--camera iscam` と `--camera iscam --with-chart` をカラーチャートをはめたまま続けて実行する**(付け外しは不要) +- `--keep`(`TIASshot.exe` の `--hwtest-keep`)を付けると,合格時も作業フォルダ(`%TEMP%\TIASshot-hwtest\work-*`)を残し,`--with-chart` では校正後のフレームとその変換画像を証拠の画像(`evidence\calibrated-frame-raw.png`・`evidence\calibrated-frame-srgbNN.png`.`NN` は変換の次元)として保存する.画像を人間に見せたいときや,色の誤差を調べるときに使う(判定は変わらない) +- 所要時間の目安(ビルドを除いた検査の部分): チャート不要の検査は約 30〜40 秒,`--with-chart` は約 50〜55 秒(校正 約 9〜10 秒・撮影 約 16 秒・保存中に閉じる 約 20〜24 秒・校正中に閉じる 約 3.5〜4 秒).実機テスト内部の全体の上限はチャート不要 600 秒・`--with-chart` 1200 秒,ランナーの打ち切りはそれぞれ 720 秒・1320 秒 - `--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 秒で打ち切った +- ランナーの終了コード: `0`=全項目合格,`1`=不合格あり(ビルド失敗・検査の不合格・タイムアウト),`2`=前提条件を満たさない(→「検証不能」.ツール未導入・TIASshot 起動中・指定機種が見つからない・引数不正) +- 実機テスト単体の終了コード(`summary.json` の `hwtest_exit_code`): `0`=全項目合格,`1`=不合格あり,`2`=実機テスト自体の異常(引数不正・指定機種が見つからない),`3`=タイムアウト(各項目の上限または全体(チャート不要 10 分・`--with-chart` 20 分).ハングの疑い),`null`=ランナーが打ち切った(チャート不要 720 秒・`--with-chart` 1320 秒) ### 検査項目 +#### チャート不要(`--with-chart` なし) + | ID | 確認内容 | 測定値と判定(しきい値は ImagingSource の実測(PLAN_06 フェーズ 1)から決めた.結果ファイルの表記は「暫定」のまま) | | --- | --- | --- | | `connect` | 指定した機種だけに接続でき,機種名・シリアル番号が取れる | 列挙した台数,`Connect()` の成否と所要時間,機種名・シリアル番号 | @@ -83,11 +88,23 @@ | `reconnect` | 切断した後に再接続できる | 2 回目の `Connect()` の成否・シリアル番号の一致・再接続後のフレームの到着(5 フレーム以上) | | `cleanup` | 後始末と書き込み禁止の確認 | 露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯はしない),の成否.実行ファイルのフォルダの `*.xml`(`config.xml`・デバイス状態ファイル)の内容が変わっていないこと,本番の保存先の今日のフォルダが変わっていないこと | +#### チャートが必要(`--with-chart`) + +`connect`・`cleanup` は上表と同じ(`cleanup` ではホワイトバランスを戻さない.校正でカメラ側の値だけが変わり,次の接続でデバイス状態ファイルの値に戻る).しきい値は ImagingSource の実測(PLAN_06 フェーズ 2)から決めた. + +| ID | 確認内容 | 測定値と判定 | +| --- | --- | --- | +| `calibrate` | 実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値に近い | 校正の待ち時間(最初のフレーム → チャート検出 → 校正完了.上限 60 秒),校正中の表示の順,校正完了後に撮影ボタンが有効になること,校正データが一時フォルダに保存されること.校正後のフレームを校正の変換行列で変換し,24 色票の平均と参照値(`tcc_srgb`)の差を **8bit(保存画像と同じ切り詰め後)・チャネルごとの差の絶対値**で判定する: 平均 5 以下,白色票を除いた最大 10 以下,白色票の最大 40 以下(**暫定**.下記「既知のノイズ」).白色票は参照値の BGR の和が最大の色票(13 番).ΔE76・白飛びの割合・当てはめの誤差(`TCC_sRGB17.csv`)は参考 | +| `shot` | 単発・連続撮影で撮影画像・sRGB 変換画像・Info.csv が保存され,撮影時刻の間隔が設定どおり | 単発 1 枚・連続 3 枚(間隔 1000 ms)を撮影し,撮影直後は保存中で撮影ボタンが無効,保存の完了(上限 90 秒)後に再有効化されること,保存されたファイル(撮影画像・変換画像・Info.csv.大きさ 0 でない)と撮影ログの行数・備考,撮影時刻の間隔と設定値の差(100 ms 以下),エラー通知が無いこと | +| `close-saving` | 保存中に閉じようとすると取り消され,保存の完了後は閉じられる | 連続 5 枚(間隔 0 ms.保存が先に終わったら 10 枚で再試行)の保存中に閉じる処理を呼び,取り消されること・表示「保存中です.保存完了後に閉じてください」・後始末が走っていないこと.保存の完了後に閉じる処理の所要時間(上限 1000 ms)・後始末の順序(closing → timer → camera → light)・閉じた後 1 秒間に届いたフレーム数(2 以下) | +| `close-calibrating` | 再接続して校正中に閉じる処理を呼んでもすぐ戻る | 再接続(シリアル番号の一致)→ 校正中になる(上限 60 秒)→ 500 ms 後に閉じる処理を呼び,所要時間(上限 1000 ms)・後始末の順序・閉じた後に届いたフレーム数(2 以下)・例外の有無 | + ### 判定の読み方 -- `hwtest-result.txt` はセルフテストと同じ書式(`[PASS] : ...` / `[FAIL] : ...` の見出し行,` - ` 情報・` WARN:` 参考・` FAIL:` 失敗理由の詳細行,最終行 `RESULT: PASS (7/7)` または `RESULT: FAIL (...)`).冒頭に暫定しきい値の一覧(`thresholds (暫定)`)と校正の扱い(`校正: 停止(チャート不要の検査…)`)が出る +- `hwtest-result.txt` はセルフテストと同じ書式(`[PASS] : ...` / `[FAIL] : ...` の見出し行,` - ` 情報・` WARN:` 参考・` FAIL:` 失敗理由の詳細行,最終行 `RESULT: PASS (7/7)`(`--with-chart` は `RESULT: PASS (6/6)`)または `RESULT: FAIL (...)`).冒頭に行う検査の一覧(`checks:`),暫定しきい値の一覧(`thresholds (暫定)`),全体の上限(`overall timeout:`),校正の扱い(`校正: 停止(チャート不要の検査…)` または `校正: 有効(チャートが必要な検査…)`)が出る - **測定値(フレームレート・平均輝度・所要時間)は PASS / FAIL にかかわらず詳細行に出る**.暫定しきい値で FAIL した場合は,測定値を報告に必ず書く(しきい値の決め直しに使う) - タイムアウト時は,止まった項目の見出しの下に途中までの測定値と `FAIL: タイムアウト: ...(実行中のステップ: ...)` が出る.どの SDK 呼び出しで止まったかはステップ名で特定する +- タイムアウト時は,その下に UI スレッド・保存スレッドのマネージドスタック(` - UI スレッド(ManagedThreadId ...)のマネージドスタック ...:` に続く ` at ...` の行)と,自プロセスのダンプのパス・大きさ(` - ミニダンプ: \hang-<日時>.dmp(全メモリ,... MB ...)`.書けなければ `WARN:`)も出る.ダンプは作業フォルダに残るので,報告にパスを書き,`.dmp` を Visual Studio(「マネージドのみでデバッグ」→ 並列スタック)か WinDbg(`.loadby sos clr` → `~*e !clrstack`)で開いて止まった場所を調べる - 指定機種が見つからない場合は `RESULT: FAIL (camera not found: <機種>)`(終了コード 2).機材の接続を人間に依頼する ### 前提条件 @@ -104,16 +121,19 @@ - 平均輝度・フレームレートは照明・被写体・カメラの個体差で変わる.判定は変化の向きと量で行い,絶対値は判定しない - 実測(ImagingSource DFK 33UX178・カラーチャートをはめた状態): フレームレート 13〜31 fps(実行ごとのばらつきが大きい),光源の点灯と消灯の差 149〜196,閉じる処理 75〜221 ms.これより大きく外れたら,しきい値より先に照明・チャート・機材の状態を疑う +- `--with-chart` の実測(同上.2026-10-08〜09 の 4 回): 校正の待ち時間 5.0〜7.9 秒(チャート検出まで 1.6〜3.0 秒+校正 3.4〜4.9 秒),色の誤差の平均 1.46〜1.57・白色票を除いた最大 3.47〜3.73・白色票 24.33〜27.35(ΔE76 の平均 1.17〜1.26),撮影間隔のずれ 4〜63 ms,保存は 1 枚あたり約 3.3〜11 秒,保存の完了後に閉じる 33〜76 ms,校正中に閉じる 53〜98 ms.**重いアプリ(Fusion360・Docker・WSL・ブラウザ等)が動いていると,校正・保存が 1.5〜3 倍遅くなり,撮影間隔のずれも大きくなる**(2026-10-09 の実測).保存の待ちの上限は 180 秒.これより大きく外れたら,しきい値より先にチャートのはまり方・照明・機材の状態を疑う +- **白色票の誤差が大きいのは 17 次元(3 次)の変換の性質**(上流 v1.6 でも同じ.PLAN_06「17 次元の変換のノイズ」): 白色票の入力のばらつきが約 10 倍に増幅され,8bit に切り詰めると点状のノイズ・白飛びになる(白色票の 255 の画素 約 73%).平均の色は合っている.白色票のしきい値 40 は対策が決まるまでの暫定であり,白色票だけが 40 を超えた場合は不具合と決めつけず,測定値(白色票の画素の統計・白飛びの割合)を添えて報告する - 飽和は「254 以上の画素の割合が 1% を超える」で判定する(平均輝度では判定しない).上げた向きの結果が飽和した場合,または真っ暗(平均輝度 5 以下)の場合,その向きの確認は省かれる(詳細行に「確認しない」と出る).両方とも省かれると FAIL になる.基準探しで中間の明るさにできなかった場合も FAIL になる - 露光・ゲインが設定範囲の端にあると,その向きの設定が変わらず確認が省かれる(詳細行に「反映されませんでした」と出る) - `exposure-gain` の `WARN: 元に戻した後の平均輝度が最初と離れています` は参考(判定は設定値の読み戻しで行う) - 実際のフォームが表示される(副ディスプレイがあればプレビューモニタも開く).メモリ観測ウィンドウ(`MEMMONITOR`)は開かない +- **実行中はユーザーに画面(TIASshot のウィンドウ)を操作しないよう案内する**.実行中にウィンドウを閉じると閉じる処理が検査の待機の中で割り込み,検査が戻らずタイムアウトしうる(2026-10-09 に撮影中に閉じられて shot がタイムアウトした).合図を受けたら,実行前に「画面に触らないでください」と伝える ### 後始末 - 実機テストは終了時(失敗時も)に「変えた露光・ゲインを元に戻す → カメラ切断 → 光源のポートを閉じる」を行い,`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-*` の有無とパスを報告の「後始末」に書く +- 書き込み先は `%TEMP%\TIASshot-hwtest\work-<日時>-\`(一時設定ファイル `config.hwtest.xml`.チャート不要の検査では校正を止めるため校正データは書かれない.`--with-chart` では校正データ・撮影データ(`data\<日付>\`)と,`--keep` 指定時は証拠の画像(`evidence\`)が書かれる)と `.verify/hwtest/run-<日時>/` だけ.合格時と前提を満たさずに終わった場合(終了コード 2)は `work-*` を自動削除し(`--keep`(`--hwtest-keep`)指定時は残す),不合格(1)・タイムアウト(3)時は残して結果ファイルの末尾に `work dir kept: <パス>` と出る.残った `work-*` の有無とパスを報告の「後始末」に書く - 同梱の `config.xml`・デバイス状態ファイル(`{DeviceName}.xml`)・本番の保存先には書き込まない(`cleanup` で確認する) ## 前提条件と確認方法 (Preconditions) @@ -153,7 +173,7 @@ ## 禁止操作 (Prohibited Operations) - `--selftest` を付けない `TIASshot.exe` の起動(UI が開き,実機カメラ・光源(シリアルポート)へ接続を試みる) - - 例外: ユーザーの合図があるときの `python scripts/verify/run_hwtest.py --camera `(上記「実機テスト」節).`TIASshot.exe --hwtest` の直接実行・合図なしの実行・`Release` の exe での実行はしない + - 例外: ユーザーの合図があるときの `python scripts/verify/run_hwtest.py --camera [--with-chart] [--keep]`(上記「実機テスト」節).`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 4ddecaa..1cdfa71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: 実機自動テスト(PLAN_06 フェーズ 1)を実装し,ImagingSource の実機で 7 項目が合格した(Lumenera は未実施) +最新: 実機テストにチャートが必要な検査(校正・撮影・保存中/校正中に閉じる)を追加し ImagingSource で合格.17 次元の変換のノイズ(白色票の点状のノイズ・白飛び)は上流 v1.6 から同じと確認(対策は要判断) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/TIASshot.Tests/HwTestJudgeTests.cs b/TIASshot.Tests/HwTestJudgeTests.cs index 2afcf15..fde7a16 100644 --- a/TIASshot.Tests/HwTestJudgeTests.cs +++ b/TIASshot.Tests/HwTestJudgeTests.cs @@ -1062,6 +1062,675 @@ public void ShouldKeepWorkDir_合格と前提不足_消す(int exitCode) { Assert.That(HwTestJudge.ShouldKeepWorkDir(false, exitCode), Is.False); } + + // ================================================================ + // チャートが必要な検査(--with-chart.PLAN_06 フェーズ 2) + // 仕様: PLAN_06「チャートが必要」の表と合意済みのしきい値. + // 実測の例は .verify/hwtest/run-20261008-154111/hwtest-result.txt(DFK 33UX178.17 次元)から採った + // ================================================================ + + // ---- 検査の並び・全体の上限 ---- + + [Test] + public void CheckIds_チャートあり_接続から校正撮影閉じる検査を経て後始末の順() { + Assert.That(HwTestJudge.CheckIds(true), + Is.EqualTo(new[] { "connect", "calibrate", "shot", "close-saving", "close-calibrating", "cleanup" })); + } + + [Test] + public void CheckIds_チャートなし_従来のチャート不要の検査の順() { + Assert.That(HwTestJudge.CheckIds(false), + Is.EqualTo(new[] { "connect", "preview", "exposure-gain", "light", "close-preview", "reconnect", "cleanup" })); + } + + [Test] + public void CheckIds_チャートあり_チャート不要の検査を含まない() { + Assert.That(HwTestJudge.CheckIds(true), Has.None.AnyOf("preview", "exposure-gain", "light", "close-preview", "reconnect")); + } + + [Test] + public void OverallTimeout_チャートなし_600秒() { + Assert.That(HwTestJudge.OverallTimeout(false), Is.EqualTo(TimeSpan.FromSeconds(600))); + } + + [Test] + public void OverallTimeout_チャートあり_1200秒() { + Assert.That(HwTestJudge.OverallTimeout(true), Is.EqualTo(TimeSpan.FromSeconds(1200))); + } + + [Test] + public void OverallTimeout_チャートあり_チャートありの項目の上限の合計より長い() { + var sum = HwTestThresholds.ConnectTimeout + HwTestThresholds.CalibrateTimeout + HwTestThresholds.ShotTimeout + + HwTestThresholds.CloseSavingTimeout + HwTestThresholds.CloseCalibratingTimeout + HwTestThresholds.CleanupTimeout; + Assert.That(HwTestJudge.OverallTimeout(true), Is.GreaterThan(sum)); + } + + // ---- 合意済みのしきい値 ---- + + [Test] + public void Thresholds_校正の待ち時間_60秒() { + Assert.That(HwTestThresholds.CalibrationWait, Is.EqualTo(TimeSpan.FromSeconds(60))); + } + + [Test] + public void Thresholds_色の誤差の平均の上限_5() { + Assert.That(HwTestThresholds.MaxCalibrationMeanError, Is.EqualTo(5.0)); + } + + [Test] + public void Thresholds_白色票を除いた最大の上限_10() { + Assert.That(HwTestThresholds.MaxCalibrationMaxError, Is.EqualTo(10.0)); + } + + [Test] + public void Thresholds_白色票の最大の上限_40() { + Assert.That(HwTestThresholds.MaxCalibrationWhiteMaxError, Is.EqualTo(40.0)); + } + + [Test] + public void Thresholds_撮影間隔のずれの許容_100ms() { + Assert.That(HwTestThresholds.ShotIntervalToleranceMs, Is.EqualTo(100.0)); + } + + [Test] + public void Thresholds_保存の待ち_180秒() { + Assert.That(HwTestThresholds.SaveWait, Is.EqualTo(TimeSpan.FromSeconds(180))); + } + + [Test] + public void Thresholds_校正中になるまでの待ち_60秒() { + Assert.That(HwTestThresholds.CalibratingWait, Is.EqualTo(TimeSpan.FromSeconds(60))); + } + + [Test] + public void Thresholds_閉じる処理の上限_1000ms() { + Assert.That(HwTestThresholds.CloseLimit, Is.EqualTo(TimeSpan.FromMilliseconds(1000))); + } + + [Test] + public void Thresholds_閉じた後のフレームの上限_2() { + Assert.That(HwTestThresholds.MaxFramesAfterClose, Is.EqualTo(2)); + } + + // ---- 時刻の差 ---- + + [Test] + public void TimestampDeltaMs_周波数で割ってミリ秒にする() { + Assert.That(HwTestJudge.TimestampDeltaMs(1000, 3500, 1000), Is.EqualTo(2500.0).Within(Eps)); + } + + [Test] + public void TimestampDeltaMs_高分解能の周波数_ミリ秒に換算する() { + // Stopwatch.Frequency の典型値 10 MHz で 1 秒の差 + Assert.That(HwTestJudge.TimestampDeltaMs(5000000, 15000000, 10000000), Is.EqualTo(1000.0).Within(Eps)); + } + + [Test] + public void TimestampDeltaMs_同じ時刻_0() { + Assert.That(HwTestJudge.TimestampDeltaMs(1234, 1234, 1000), Is.EqualTo(0.0).Within(Eps)); + } + + [Test] + public void TimestampDeltaMs_開始が未記録の0_NaN() { + Assert.That(HwTestJudge.TimestampDeltaMs(0, 1000, 1000), Is.NaN); + } + + [Test] + public void TimestampDeltaMs_終了が未記録の0_NaN() { + Assert.That(HwTestJudge.TimestampDeltaMs(1000, 0, 1000), Is.NaN); + } + + [Test] + public void TimestampDeltaMs_逆順_NaN() { + Assert.That(HwTestJudge.TimestampDeltaMs(2000, 1000, 1000), Is.NaN); + } + + [TestCase(0L)] + [TestCase(-1000L)] + public void TimestampDeltaMs_周波数が0以下_NaN(long frequency) { + Assert.That(HwTestJudge.TimestampDeltaMs(1000, 2000, frequency), Is.NaN); + } + + // ---- 色の誤差の判定 ---- + + private static bool JudgeColor(double meanAbs, double otherMax, double whiteMax) { + return HwTestJudge.JudgeColorError(meanAbs, otherMax, whiteMax, + HwTestThresholds.MaxCalibrationMeanError, HwTestThresholds.MaxCalibrationMaxError, HwTestThresholds.MaxCalibrationWhiteMaxError); + } + + [Test] + public void JudgeColorError_実測の値_合格() { + // 2026-10-08 15:41 の実測(17 次元): 平均 1.57・白色票を除いた最大 3.47・白色票 24.65 + Assert.That(JudgeColor(1.57, 3.47, 24.65), Is.True); + } + + [Test] + public void JudgeColorError_すべて上限ちょうど_合格() { + Assert.That(JudgeColor(5.0, 10.0, 40.0), Is.True); + } + + [Test] + public void JudgeColorError_平均が上限超え_不合格() { + Assert.That(JudgeColor(5.01, 3.47, 24.65), Is.False); + } + + [Test] + public void JudgeColorError_白色票を除いた最大が上限超え_不合格() { + Assert.That(JudgeColor(1.57, 10.01, 24.65), Is.False); + } + + [Test] + public void JudgeColorError_白色票の最大が上限超え_不合格() { + Assert.That(JudgeColor(1.57, 3.47, 40.01), Is.False); + } + + [Test] + public void JudgeColorError_白色票の誤差は白色票の上限で見る_白色票以外の上限を超えても合格() { + // 白色票の 30 は白色票以外の上限 10 を超えるが,白色票の上限 40 以内なので合格 + Assert.That(JudgeColor(1.57, 3.47, 30.0), Is.True); + } + + [TestCase(double.NaN, 3.47, 24.65)] + [TestCase(1.57, double.NaN, 24.65)] + [TestCase(1.57, 3.47, double.NaN)] + public void JudgeColorError_いずれかがNaN_不合格(double meanAbs, double otherMax, double whiteMax) { + Assert.That(JudgeColor(meanAbs, otherMax, whiteMax), Is.False); + } + + [Test] + public void JudgeColorError_上限を引数で受け取る_引数の上限で判定する() { + Assert.That(HwTestJudge.JudgeColorError(2.0, 2.0, 2.0, 1.0, 100.0, 100.0), Is.False); + } + + // ---- 撮影ログ(Info.csv)の読み取り ---- + + [Test] + public void ParseShotLog_連続撮影のログ_番号と時刻を読む() { + var lines = new[] { "Shot,Time(ms),Note", "1,0", "2,1002", "3,2006" }; + var rows = HwTestJudge.ParseShotLog(lines, out _); + Assert.That(rows.ConvertAll(r => r.TimeMs), Is.EqualTo(new[] { 0L, 1002L, 2006L })); + } + + [Test] + public void ParseShotLog_連続撮影のログ_番号を読む() { + var lines = new[] { "Shot,Time(ms),Note", "1,0", "2,1002", "3,2006" }; + var rows = HwTestJudge.ParseShotLog(lines, out _); + Assert.That(rows.ConvertAll(r => r.Shot), Is.EqualTo(new[] { 1, 2, 3 })); + } + + [Test] + public void ParseShotLog_正常なログ_errorはnull() { + var lines = new[] { "Shot,Time(ms),Note", "1,0" }; + HwTestJudge.ParseShotLog(lines, out var error); + Assert.That(error, Is.Null); + } + + [Test] + public void ParseShotLog_備考なし_備考は空() { + var rows = HwTestJudge.ParseShotLog(new[] { "Shot,Time(ms),Note", "1,0" }, out _); + Assert.That(rows[0].Note, Is.EqualTo("")); + } + + [Test] + public void ParseShotLog_見出しより前の行_読み飛ばす() { + var lines = new[] { "Name,hwtest-shot-multi", "Date,2026-10-08", "Shot,Time(ms),Note", "1,0", "2,1002" }; + var rows = HwTestJudge.ParseShotLog(lines, out var error); + Assert.That(rows.Count, Is.EqualTo(2)); + } + + [Test] + public void ParseShotLog_空行_読み飛ばす() { + var lines = new[] { "Shot,Time(ms),Note", "1,0", "", "2,1002", "" }; + var rows = HwTestJudge.ParseShotLog(lines, out var error); + Assert.That(rows.Count, Is.EqualTo(2)); + } + + [Test] + public void ParseShotLog_カンマを含むエスケープした備考_エスケープのまま残す() { + var note = "\"撮影失敗のため中止(a,b \"\"x\"\")\""; + var rows = HwTestJudge.ParseShotLog(new[] { "Shot,Time(ms),Note", "1,0", "2,1002," + note }, out _); + Assert.That(rows[1].Note, Is.EqualTo(note)); + } + + [Test] + public void ParseShotLog_見出し行が無い_errorを返す() { + HwTestJudge.ParseShotLog(new[] { "1,0", "2,1002" }, out var error); + Assert.That(error, Is.Not.Null); + } + + [Test] + public void ParseShotLog_見出し行が無い_行を返さない() { + var rows = HwTestJudge.ParseShotLog(new[] { "1,0", "2,1002" }, out _); + Assert.That(rows, Is.Empty); + } + + [Test] + public void ParseShotLog_時刻が数値でない行_errorを返す() { + HwTestJudge.ParseShotLog(new[] { "Shot,Time(ms),Note", "1,0", "2,abc" }, out var error); + Assert.That(error, Is.Not.Null); + } + + [Test] + public void ParseShotLog_番号が数値でない行_errorを返す() { + HwTestJudge.ParseShotLog(new[] { "Shot,Time(ms),Note", "x,0" }, out var error); + Assert.That(error, Is.Not.Null); + } + + [Test] + public void ParseShotLog_列が足りない行_errorを返す() { + HwTestJudge.ParseShotLog(new[] { "Shot,Time(ms),Note", "1" }, out var error); + Assert.That(error, Is.Not.Null); + } + + [Test] + public void ParseShotLog_途中で読めない行_それまでの行を返す() { + var rows = HwTestJudge.ParseShotLog(new[] { "Shot,Time(ms),Note", "1,0", "2,abc", "3,2006" }, out _); + Assert.That(rows.Count, Is.EqualTo(1)); + } + + [Test] + public void ParseShotLog_null_errorを返す() { + HwTestJudge.ParseShotLog(null, out var error); + Assert.That(error, Is.Not.Null); + } + + // ---- 撮影間隔のずれ ---- + + [Test] + public void MaxIntervalDeviationMs_実測の撮影時刻_最大のずれ() { + // 0/1002/2006: 間隔 1002・1004 → 設定 1000 との差 2・4 の最大 4 + Assert.That(HwTestJudge.MaxIntervalDeviationMs(new long[] { 0, 1002, 2006 }, 1000), Is.EqualTo(4.0).Within(Eps)); + } + + [Test] + public void MaxIntervalDeviationMs_間隔が設定より短い_差の絶対値() { + Assert.That(HwTestJudge.MaxIntervalDeviationMs(new long[] { 0, 900, 1900 }, 1000), Is.EqualTo(100.0).Within(Eps)); + } + + [Test] + public void MaxIntervalDeviationMs_間隔0の連続撮影_間隔そのものがずれ() { + Assert.That(HwTestJudge.MaxIntervalDeviationMs(new long[] { 0, 40, 73, 107, 138 }, 0), Is.EqualTo(40.0).Within(Eps)); + } + + [Test] + public void MaxIntervalDeviationMs_設定どおり_0() { + Assert.That(HwTestJudge.MaxIntervalDeviationMs(new long[] { 0, 1000, 2000 }, 1000), Is.EqualTo(0.0).Within(Eps)); + } + + [Test] + public void MaxIntervalDeviationMs_1件_NaN() { + Assert.That(HwTestJudge.MaxIntervalDeviationMs(new long[] { 0 }, 1000), Is.NaN); + } + + [Test] + public void MaxIntervalDeviationMs_0件_NaN() { + Assert.That(HwTestJudge.MaxIntervalDeviationMs(new long[0], 1000), Is.NaN); + } + + [Test] + public void MaxIntervalDeviationMs_null_NaN() { + Assert.That(HwTestJudge.MaxIntervalDeviationMs(null, 1000), Is.NaN); + } + + // ---- 保存されるファイル名 ---- + + [Test] + public void ExpectedShotFiles_単発撮影で1次元_InfoとShotとsRGBの3件() { + var names = HwTestJudge.ExpectedShotFiles("Shot{NO}.png", "sRGB{CN}_{NO}.jpg", "Info.csv", new[] { 17 }, 1); + Assert.That(names, Is.EquivalentTo(new[] { "Info.csv", "Shot0001.png", "sRGB17_0001.jpg" })); + } + + [Test] + public void ExpectedShotFiles_連続3枚で1次元_7件() { + var names = HwTestJudge.ExpectedShotFiles("Shot{NO}.png", "sRGB{CN}_{NO}.jpg", "Info.csv", new[] { 17 }, 3); + Assert.That(names.Count, Is.EqualTo(7)); + } + + [Test] + public void ExpectedShotFiles_番号は4桁の1始まり() { + var names = HwTestJudge.ExpectedShotFiles("Shot{NO}.png", "sRGB{CN}_{NO}.jpg", "Info.csv", new int[0], 3); + Assert.That(names, Is.EquivalentTo(new[] { "Info.csv", "Shot0001.png", "Shot0002.png", "Shot0003.png" })); + } + + [Test] + public void ExpectedShotFiles_項目数は2桁() { + var names = HwTestJudge.ExpectedShotFiles("Shot{NO}.png", "sRGB{CN}_{NO}.jpg", "Info.csv", new[] { 4, 17 }, 1); + Assert.That(names, Is.EquivalentTo(new[] { "Info.csv", "Shot0001.png", "sRGB04_0001.jpg", "sRGB17_0001.jpg" })); + } + + [Test] + public void ExpectedShotFiles_項目数がnull_sRGB変換画像なし() { + var names = HwTestJudge.ExpectedShotFiles("Shot{NO}.png", "sRGB{CN}_{NO}.jpg", "Info.csv", null, 2); + Assert.That(names, Is.EquivalentTo(new[] { "Info.csv", "Shot0001.png", "Shot0002.png" })); + } + + [Test] + public void ExpectedShotFiles_枚数0_Infoだけ() { + var names = HwTestJudge.ExpectedShotFiles("Shot{NO}.png", "sRGB{CN}_{NO}.jpg", "Info.csv", new[] { 17 }, 0); + Assert.That(names, Is.EquivalentTo(new[] { "Info.csv" })); + } + + [Test] + public void ExpectedShotFiles_書式がnull_ArgumentNullException() { + Assert.Throws((Action)(() => + HwTestJudge.ExpectedShotFiles(null, "sRGB{CN}_{NO}.jpg", "Info.csv", new[] { 17 }, 1))); + } + + [Test] + public void CompareFileNames_一致_足りないものも余分なものも無い() { + HwTestJudge.CompareFileNames(new[] { "Info.csv", "Shot0001.png" }, new[] { "Shot0001.png", "Info.csv" }, + out var missing, out var unexpected); + Assert.That(missing.Count + unexpected.Count, Is.EqualTo(0)); + } + + [Test] + public void CompareFileNames_大文字小文字の違い_一致とみなす() { + HwTestJudge.CompareFileNames(new[] { "Info.csv", "Shot0001.png" }, new[] { "INFO.CSV", "shot0001.PNG" }, + out var missing, out var unexpected); + Assert.That(missing.Count + unexpected.Count, Is.EqualTo(0)); + } + + [Test] + public void CompareFileNames_足りないファイル_missingに入る() { + HwTestJudge.CompareFileNames(new[] { "Info.csv", "Shot0001.png", "sRGB17_0001.jpg" }, new[] { "Info.csv" }, + out var missing, out _); + Assert.That(missing, Is.EqualTo(new[] { "Shot0001.png", "sRGB17_0001.jpg" })); + } + + [Test] + public void CompareFileNames_余分なファイル_unexpectedに入る() { + HwTestJudge.CompareFileNames(new[] { "Info.csv" }, new[] { "Info.csv", "Shot0002.png" }, + out _, out var unexpected); + Assert.That(unexpected, Is.EqualTo(new[] { "Shot0002.png" })); + } + + [Test] + public void CompareFileNames_結果は名前順() { + HwTestJudge.CompareFileNames(new[] { "c.png", "a.png", "B.png" }, new string[0], out var missing, out _); + Assert.That(missing, Is.EqualTo(new[] { "a.png", "B.png", "c.png" })); + } + + // ---- 保存中に閉じる検査 ---- + + [Test] + public void DecideSavingAttempt_保存中を観測_Ready() { + Assert.That(HwTestJudge.DecideSavingAttempt(true, 1, HwTestThresholds.CloseSavingMaxAttempts), Is.EqualTo(HwTestSavingAttempt.Ready)); + } + + [Test] + public void DecideSavingAttempt_最後の試行で保存中を観測_Ready() { + Assert.That(HwTestJudge.DecideSavingAttempt(true, 2, 2), Is.EqualTo(HwTestSavingAttempt.Ready)); + } + + [Test] + public void DecideSavingAttempt_1回目で保存中にならない_Retry() { + Assert.That(HwTestJudge.DecideSavingAttempt(false, 1, 2), Is.EqualTo(HwTestSavingAttempt.Retry)); + } + + [Test] + public void DecideSavingAttempt_上限の回で保存中にならない_GiveUp() { + Assert.That(HwTestJudge.DecideSavingAttempt(false, 2, 2), Is.EqualTo(HwTestSavingAttempt.GiveUp)); + } + + [Test] + public void Thresholds_保存中を作る試行_最初と再試行1回の2回() { + Assert.That(HwTestThresholds.CloseSavingMaxAttempts, Is.EqualTo(2)); + } + + [Test] + public void CloseSavingShotCount_1回目_5枚() { + Assert.That(HwTestJudge.CloseSavingShotCount(1), Is.EqualTo(5)); + } + + [TestCase(2)] + [TestCase(3)] + public void CloseSavingShotCount_2回目以降_10枚(int attempt) { + Assert.That(HwTestJudge.CloseSavingShotCount(attempt), Is.EqualTo(10)); + } + + private const string SavingMsg = "保存中です.保存完了後に閉じてください"; + + [Test] + public void CancelledCloseProblems_正しく取り消された_問題なし() { + Assert.That(HwTestJudge.CancelledCloseProblems(false, SavingMsg, SavingMsg, 0, false, true), Is.Empty); + } + + [Test] + public void CancelledCloseProblems_閉じると決まった_問題あり() { + Assert.That(HwTestJudge.CancelledCloseProblems(true, SavingMsg, SavingMsg, 0, false, true).Count, Is.EqualTo(1)); + } + + [Test] + public void CancelledCloseProblems_表示が違う_問題に表示を含める() { + var problems = HwTestJudge.CancelledCloseProblems(false, "全ての画像保存完了", SavingMsg, 0, false, true); + Assert.That(problems, Has.Exactly(1).Contains("全ての画像保存完了")); + } + + [Test] + public void CancelledCloseProblems_表示がnull_問題あり() { + Assert.That(HwTestJudge.CancelledCloseProblems(false, null, SavingMsg, 0, false, true).Count, Is.EqualTo(1)); + } + + [Test] + public void CancelledCloseProblems_後始末が進んだ_問題あり() { + Assert.That(HwTestJudge.CancelledCloseProblems(false, SavingMsg, SavingMsg, 1, false, true).Count, Is.EqualTo(1)); + } + + [Test] + public void CancelledCloseProblems_閉じ始めのフラグが立った_問題あり() { + Assert.That(HwTestJudge.CancelledCloseProblems(false, SavingMsg, SavingMsg, 0, true, true).Count, Is.EqualTo(1)); + } + + [Test] + public void CancelledCloseProblems_再描画タイマーが止まった_問題あり() { + Assert.That(HwTestJudge.CancelledCloseProblems(false, SavingMsg, SavingMsg, 0, false, false).Count, Is.EqualTo(1)); + } + + [Test] + public void CancelledCloseProblems_すべて異常_5件() { + Assert.That(HwTestJudge.CancelledCloseProblems(true, "x", SavingMsg, 4, true, false).Count, Is.EqualTo(5)); + } + + // ---- 後始末の順序 ---- + + [Test] + public void IsCloseSequenceSince_開始位置から正しい順_true() { + var steps = new[] { "closing", "timer", "camera", "light", "closing", "timer", "camera", "light" }; + Assert.That(HwTestJudge.IsCloseSequenceSince(steps, 4), Is.True); + } + + [Test] + public void IsCloseSequenceSince_開始位置0から正しい順_true() { + Assert.That(HwTestJudge.IsCloseSequenceSince(new[] { "closing", "timer", "camera", "light" }, 0), Is.True); + } + + [Test] + public void IsCloseSequenceSince_順序が違う_false() { + Assert.That(HwTestJudge.IsCloseSequenceSince(new[] { "closing", "camera", "timer", "light" }, 0), Is.False); + } + + [Test] + public void IsCloseSequenceSince_段階が足りない_false() { + Assert.That(HwTestJudge.IsCloseSequenceSince(new[] { "closing", "timer", "camera" }, 0), Is.False); + } + + [Test] + public void IsCloseSequenceSince_余分な段階がある_false() { + Assert.That(HwTestJudge.IsCloseSequenceSince(new[] { "closing", "timer", "camera", "light", "light" }, 0), Is.False); + } + + [Test] + public void IsCloseSequenceSince_何も進んでいない_false() { + Assert.That(HwTestJudge.IsCloseSequenceSince(new[] { "closing", "timer", "camera", "light" }, 4), Is.False); + } + + [TestCase(-1)] + [TestCase(5)] + public void IsCloseSequenceSince_開始位置が範囲外_false(int start) { + Assert.That(HwTestJudge.IsCloseSequenceSince(new[] { "closing", "timer", "camera", "light" }, start), Is.False); + } + + [Test] + public void IsCloseSequenceSince_null_false() { + Assert.That(HwTestJudge.IsCloseSequenceSince(null, 0), Is.False); + } + + [Test] + public void StepsSince_開始位置以降を返す() { + Assert.That(HwTestJudge.StepsSince(new[] { "closing", "timer", "closing", "timer" }, 2), Is.EqualTo(new[] { "closing", "timer" })); + } + + [Test] + public void StepsSince_開始位置が末尾_空() { + Assert.That(HwTestJudge.StepsSince(new[] { "closing" }, 1), Is.Empty); + } + + [Test] + public void StepsSince_開始位置が負_全部() { + Assert.That(HwTestJudge.StepsSince(new[] { "closing", "timer" }, -1), Is.EqualTo(new[] { "closing", "timer" })); + } + + [Test] + public void StepsSince_null_空() { + Assert.That(HwTestJudge.StepsSince(null, 0), Is.Empty); + } + + // ---- 色票内の画素の集計(白飛び・黒つぶれ) ---- + + [Test] + public void SummarizePatchPixels_画素の数() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 1, 2, 3, 4, 5, 6 }); + Assert.That(s.Pixels, Is.EqualTo(2)); + } + + [Test] + public void SummarizePatchPixels_チャネルごとの平均_BGRの順() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 10, 20, 30, 30, 40, 50 }); + Assert.That(s.Mean, Is.EqualTo(new[] { 20.0, 30.0, 40.0 }).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_チャネルごとの最小() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 10, 60, 30, 30, 40, 50 }); + Assert.That(s.Min, Is.EqualTo(new[] { 10.0, 40.0, 30.0 }).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_チャネルごとの最大() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 10, 60, 30, 30, 40, 50 }); + Assert.That(s.Max, Is.EqualTo(new[] { 30.0, 60.0, 50.0 }).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_255の画素_白飛びの割合に数える() { + // 4 画素のうち 1 画素が G=255(8bit の頭打ち) + var s = HwTestJudge.SummarizePatchPixels(new double[] { 100, 255, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100 }); + Assert.That(s.FractionAnyAtOrAboveMax, Is.EqualTo(0.25).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_255ちょうど_超えた割合には数えない() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 255, 255, 255 }); + Assert.That(s.FractionAnyAboveMax, Is.EqualTo(0.0).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_255を超えた画素_超えた割合に数える() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 621.4, 200, 200, 100, 100, 100 }); + Assert.That(s.FractionAnyAboveMax, Is.EqualTo(0.5).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_255を超えた画素_以上の割合にも数える() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 621.4, 200, 200, 100, 100, 100 }); + Assert.That(s.FractionAnyAtOrAboveMax, Is.EqualTo(0.5).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_0の画素_黒つぶれの割合に数える() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 0, 2, 0, 100, 100, 100 }); + Assert.That(s.FractionAnyAtOrBelowMin, Is.EqualTo(0.5).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_0ちょうど_0未満の割合には数えない() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 0, 0, 0 }); + Assert.That(s.FractionAnyBelowMin, Is.EqualTo(0.0).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_0未満の画素_0未満の割合と以下の割合に数える() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { -175.6, 2, -42.3, 100, 100, 100 }); + Assert.That(new[] { s.FractionAnyBelowMin, s.FractionAnyAtOrBelowMin }, Is.EqualTo(new[] { 0.5, 0.5 }).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_1画素で複数チャネルが255_1画素として数える() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 255, 255, 255, 100, 100, 100 }); + Assert.That(s.FractionAnyAtOrAboveMax, Is.EqualTo(0.5).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_範囲内の画素だけ_割合はすべて0() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { 1, 128, 254, 100, 100, 100 }); + Assert.That(new[] { s.FractionAnyAtOrAboveMax, s.FractionAnyAtOrBelowMin, s.FractionAnyAboveMax, s.FractionAnyBelowMin }, + Is.EqualTo(new[] { 0.0, 0.0, 0.0, 0.0 })); + } + + [Test] + public void SummarizePatchPixels_空_画素数0() { + var s = HwTestJudge.SummarizePatchPixels(new double[0]); + Assert.That(s.Pixels, Is.EqualTo(0)); + } + + [Test] + public void SummarizePatchPixels_空_平均最小最大はNaN() { + var s = HwTestJudge.SummarizePatchPixels(new double[0]); + Assert.That(new[] { s.Mean[0], s.Mean[1], s.Mean[2], s.Min[0], s.Min[1], s.Min[2], s.Max[0], s.Max[1], s.Max[2] }, Has.All.NaN); + } + + [Test] + public void SummarizePatchPixels_空_割合4種はNaN() { + var s = HwTestJudge.SummarizePatchPixels(new double[0]); + Assert.That(new[] { s.FractionAnyAtOrAboveMax, s.FractionAnyAtOrBelowMin, s.FractionAnyAboveMax, s.FractionAnyBelowMin }, Has.All.NaN); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(4)] + public void SummarizePatchPixels_長さが3の倍数でない_ArgumentException(int length) { + Assert.Throws((Action)(() => HwTestJudge.SummarizePatchPixels(new double[length]))); + } + + [Test] + public void SummarizePatchPixels_null_ArgumentNullException() { + Assert.Throws((Action)(() => HwTestJudge.SummarizePatchPixels(null))); + } + + [Test] + public void SummarizePatchPixels_NaNの画素_そのチャネルの平均はNaN() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { double.NaN, 10, 10, 20, 20, 20 }); + Assert.That(s.Mean[0], Is.NaN); + } + + [Test] + public void SummarizePatchPixels_NaNの画素_他のチャネルの平均は影響しない() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { double.NaN, 10, 10, 20, 20, 20 }); + Assert.That(new[] { s.Mean[1], s.Mean[2] }, Is.EqualTo(new[] { 15.0, 15.0 }).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_NaNの画素_最小最大に数えない() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { double.NaN, 10, 10, 20, 20, 20 }); + Assert.That(new[] { s.Min[0], s.Max[0] }, Is.EqualTo(new[] { 20.0, 20.0 }).Within(Eps)); + } + + [Test] + public void SummarizePatchPixels_NaNの画素_割合に数えない() { + var s = HwTestJudge.SummarizePatchPixels(new double[] { double.NaN, double.NaN, double.NaN, 100, 100, 100 }); + Assert.That(new[] { s.FractionAnyAtOrAboveMax, s.FractionAnyAtOrBelowMin, s.FractionAnyAboveMax, s.FractionAnyBelowMin }, + Is.EqualTo(new[] { 0.0, 0.0, 0.0, 0.0 })); + } } } #endif diff --git a/TIASshot.Tests/PatchColorErrorTests.cs b/TIASshot.Tests/PatchColorErrorTests.cs new file mode 100644 index 0000000..d6e9d09 --- /dev/null +++ b/TIASshot.Tests/PatchColorErrorTests.cs @@ -0,0 +1,405 @@ +#if SELFTEST +using System; +using System.IO; +using NUnit.Framework; +using OpenCvSharp; + +namespace TIASshot.Tests { + /// + /// 色票の測定値と参照値(tcc_srgb)の差の集計(PatchColorError)のテスト(層①.純粋関数). + /// + /// 仕様: PLAN_06「チャートが必要」の calibrate(変換後の色票と参照値の色差)と合意済みの判定方法 + /// - 値の並びは 1 行 = 1 色票,列 0 = B・列 1 = G・列 2 = R(8bit 階調) + /// - 色の誤差はチャネルごとの |測定 − 参照|.平均の分母は 色票の数 × 3.NaN があれば平均・最大とも NaN + /// - 白色票は参照値の BGR の和が最大の色票(同値なら先の行).tcc_srgb.csv では 0 始まりの 12(13 番) + /// - 白色票を除いた最大(範囲外の除外指定は何も除かない.残りが無ければ NaN と -1) + /// - 参考の ΔE は CIE76(sRGB D65 → CIELAB).0〜255 の外は丸めずにガンマの式を符号つきで延長する + /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる. + /// + [TestFixture] + public class PatchColorErrorTests { + + private const double Eps = 1e-9; + + // ================================================================ + // Compare + // ================================================================ + + private static readonly double[,] Measured2 = { { 10, 20, 30 }, { 40, 50, 60 } }; + // 差の絶対値: 1, 2, 0 / 0, 0, 6 + private static readonly double[,] Reference2 = { { 11, 18, 30 }, { 40, 50, 66 } }; + + [Test] + public void Compare_色票の数() { + Assert.That(PatchColorError.Compare(Measured2, Reference2).Patches, Is.EqualTo(2)); + } + + [Test] + public void Compare_平均_分母は色票の数かける3() { + // (1 + 2 + 0 + 0 + 0 + 6) / (2 × 3) = 1.5 + Assert.That(PatchColorError.Compare(Measured2, Reference2).MeanAbs, Is.EqualTo(1.5).Within(Eps)); + } + + [Test] + public void Compare_最大_チャネルごとの差の絶対値の最大() { + Assert.That(PatchColorError.Compare(Measured2, Reference2).MaxAbs, Is.EqualTo(6.0).Within(Eps)); + } + + [Test] + public void Compare_最大の色票_0始まりの番号() { + Assert.That(PatchColorError.Compare(Measured2, Reference2).MaxAbsPatch, Is.EqualTo(1)); + } + + [Test] + public void Compare_測定が参照より小さい_差は絶対値() { + var s = PatchColorError.Compare(new double[,] { { 0, 0, 0 } }, new double[,] { { 3, 3, 3 } }); + Assert.That(s.MeanAbs, Is.EqualTo(3.0).Within(Eps)); + } + + [Test] + public void Compare_一致_平均は0() { + Assert.That(PatchColorError.Compare(Measured2, Measured2).MeanAbs, Is.EqualTo(0.0).Within(Eps)); + } + + [Test] + public void Compare_一致_DeltaEの最大は0() { + Assert.That(PatchColorError.Compare(Measured2, Measured2).MaxDeltaE, Is.EqualTo(0.0).Within(Eps)); + } + + [Test] + public void Compare_白と黒_DeltaEの平均は100() { + var s = PatchColorError.Compare(new double[,] { { 255, 255, 255 } }, new double[,] { { 0, 0, 0 } }); + Assert.That(s.MeanDeltaE, Is.EqualTo(100.0).Within(0.01)); + } + + [Test] + public void Compare_DeltaEの最大の色票_0始まりの番号() { + var s = PatchColorError.Compare(new double[,] { { 100, 100, 100 }, { 255, 255, 255 } }, + new double[,] { { 100, 100, 100 }, { 0, 0, 0 } }); + Assert.That(s.MaxDeltaEPatch, Is.EqualTo(1)); + } + + [Test] + public void Compare_NaNを含む_平均はNaN() { + var measured = new double[,] { { 10, double.NaN, 30 }, { 40, 50, 60 } }; + Assert.That(PatchColorError.Compare(measured, Reference2).MeanAbs, Is.NaN); + } + + [Test] + public void Compare_NaNを含む_最大はNaN() { + // NaN の後に大きい差(6)があっても最大は NaN(誤差を見逃さない) + var measured = new double[,] { { 10, double.NaN, 30 }, { 40, 50, 60 } }; + Assert.That(PatchColorError.Compare(measured, Reference2).MaxAbs, Is.NaN); + } + + [Test] + public void Compare_最後の色票にNaN_最大はNaN() { + var measured = new double[,] { { 0, 0, 0 }, { 40, 50, double.NaN } }; + var reference = new double[,] { { 100, 100, 100 }, { 40, 50, 60 } }; + Assert.That(PatchColorError.Compare(measured, reference).MaxAbs, Is.NaN); + } + + [Test] + public void Compare_参照値にNaN_最大はNaN() { + var reference = new double[,] { { 11, 18, 30 }, { double.NaN, 50, 66 } }; + Assert.That(PatchColorError.Compare(Measured2, reference).MaxAbs, Is.NaN); + } + + [Test] + public void Compare_列が3でない_ArgumentException() { + Assert.Throws((Action)(() => + PatchColorError.Compare(new double[2, 4], new double[2, 4]))); + } + + [Test] + public void Compare_行数が違う_ArgumentException() { + Assert.Throws((Action)(() => + PatchColorError.Compare(new double[2, 3], new double[3, 3]))); + } + + [Test] + public void Compare_0行_ArgumentException() { + Assert.Throws((Action)(() => + PatchColorError.Compare(new double[0, 3], new double[0, 3]))); + } + + [Test] + public void Compare_測定がnull_ArgumentNullException() { + Assert.Throws((Action)(() => PatchColorError.Compare(null, Reference2))); + } + + [Test] + public void Compare_参照がnull_ArgumentNullException() { + Assert.Throws((Action)(() => PatchColorError.Compare(Measured2, null))); + } + + // ================================================================ + // SrgbToLab・DeltaE76 + // ================================================================ + + [Test] + public void SrgbToLab_白_Lは100() { + PatchColorError.SrgbToLab(255, 255, 255, out var l, out _, out _); + Assert.That(l, Is.EqualTo(100.0).Within(0.01)); + } + + [Test] + public void SrgbToLab_白_aとbは0() { + PatchColorError.SrgbToLab(255, 255, 255, out _, out var a, out var b); + Assert.That(new[] { a, b }, Is.EqualTo(new[] { 0.0, 0.0 }).Within(0.01)); + } + + [Test] + public void SrgbToLab_黒_Labはすべて0() { + PatchColorError.SrgbToLab(0, 0, 0, out var l, out var a, out var b); + Assert.That(new[] { l, a, b }, Is.EqualTo(new[] { 0.0, 0.0, 0.0 }).Within(1e-6)); + } + + [Test] + public void SrgbToLab_中間の灰色128_Lは約53_59() { + // sRGB (128,128,128) → L* 53.585(D65) + PatchColorError.SrgbToLab(128, 128, 128, out var l, out _, out _); + Assert.That(l, Is.EqualTo(53.585).Within(0.01)); + } + + [Test] + public void SrgbToLab_赤_既知のLab() { + // sRGB (255,0,0) → L* 53.24, a* 80.09, b* 67.20(D65) + PatchColorError.SrgbToLab(255, 0, 0, out var l, out var a, out var b); + Assert.That(new[] { l, a, b }, Is.EqualTo(new[] { 53.24, 80.09, 67.20 }).Within(0.05)); + } + + [Test] + public void SrgbToLab_青_既知のLab() { + // sRGB (0,0,255) → L* 32.30, a* 79.19, b* -107.86(D65) + PatchColorError.SrgbToLab(0, 0, 255, out var l, out var a, out var b); + Assert.That(new[] { l, a, b }, Is.EqualTo(new[] { 32.30, 79.19, -107.86 }).Within(0.05)); + } + + [Test] + public void SrgbToLab_255を超える値_丸めずにLが100を超える() { + PatchColorError.SrgbToLab(300, 300, 300, out var l, out _, out _); + Assert.That(l, Is.GreaterThan(100.0)); + } + + [Test] + public void SrgbToLab_0未満の値_丸めずにLが負() { + PatchColorError.SrgbToLab(-10, -10, -10, out var l, out _, out _); + Assert.That(l, Is.LessThan(0.0)); + } + + [Test] + public void SrgbToLab_0未満の値_符号つきで延長する() { + // 暗部(L* の線形部分)では,符号を反転した入力の L* は符号が反転する + PatchColorError.SrgbToLab(10, 10, 10, out var lPos, out _, out _); + PatchColorError.SrgbToLab(-10, -10, -10, out var lNeg, out _, out _); + Assert.That(lNeg, Is.EqualTo(-lPos).Within(1e-9)); + } + + [Test] + public void DeltaE76_同じ色_0() { + Assert.That(PatchColorError.DeltaE76(50, 120, 200, 50, 120, 200), Is.EqualTo(0.0).Within(Eps)); + } + + [Test] + public void DeltaE76_白と黒_100() { + Assert.That(PatchColorError.DeltaE76(255, 255, 255, 0, 0, 0), Is.EqualTo(100.0).Within(0.01)); + } + + [Test] + public void DeltaE76_引数はBGRの順_赤と黒() { + // BGR (0,0,255) = 赤.Lab (53.24, 80.09, 67.20) と黒の距離 ≒ 117.34 + Assert.That(PatchColorError.DeltaE76(0, 0, 255, 0, 0, 0), Is.EqualTo(117.34).Within(0.1)); + } + + [Test] + public void DeltaE76_対称() { + Assert.That(PatchColorError.DeltaE76(10, 200, 30, 90, 80, 70), + Is.EqualTo(PatchColorError.DeltaE76(90, 80, 70, 10, 200, 30)).Within(Eps)); + } + + // ================================================================ + // ToArray + // ================================================================ + + [Test] + public void ToArray_nかける3のMat_同じ値の配列() { + using (var m = new Mat(2, 3, MatType.CV_64FC1)) { + m.Set(0, 0, 1.5); m.Set(0, 1, 2.5); m.Set(0, 2, 3.5); + m.Set(1, 0, 4.5); m.Set(1, 1, 5.5); m.Set(1, 2, 6.5); + Assert.That(PatchColorError.ToArray(m), Is.EqualTo(new double[,] { { 1.5, 2.5, 3.5 }, { 4.5, 5.5, 6.5 } })); + } + } + + [Test] + public void ToArray_列が3でない_ArgumentException() { + using (var m = new Mat(2, 4, MatType.CV_64FC1, Scalar.All(0))) { + Assert.Throws((Action)(() => PatchColorError.ToArray(m))); + } + } + + [Test] + public void ToArray_3チャネルのMat_ArgumentException() { + using (var m = new Mat(2, 3, MatType.CV_64FC3, Scalar.All(0))) { + Assert.Throws((Action)(() => PatchColorError.ToArray(m))); + } + } + + [Test] + public void ToArray_null_ArgumentNullException() { + Assert.Throws((Action)(() => PatchColorError.ToArray(null))); + } + + // ================================================================ + // WhitePatchIndex + // ================================================================ + + [Test] + public void WhitePatchIndex_BGRの和が最大の行() { + var reference = new double[,] { { 100, 100, 100 }, { 200, 210, 220 }, { 250, 10, 10 } }; + Assert.That(PatchColorError.WhitePatchIndex(reference), Is.EqualTo(1)); + } + + [Test] + public void WhitePatchIndex_和が同じ_先の行() { + var reference = new double[,] { { 10, 10, 10 }, { 200, 200, 200 }, { 100, 200, 300 } }; + Assert.That(PatchColorError.WhitePatchIndex(reference), Is.EqualTo(1)); + } + + [Test] + public void WhitePatchIndex_1行_0() { + Assert.That(PatchColorError.WhitePatchIndex(new double[,] { { 1, 2, 3 } }), Is.EqualTo(0)); + } + + [Test] + public void WhitePatchIndex_同梱のtcc_srgb_12() { + var path = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "TIASshot", "tcc_srgb.csv")); + Assert.That(File.Exists(path), Is.True, $"tcc_srgb.csv が見つからない: {path}"); + using (var m = IoUtil.LoadMatFromCsv(path)) { + Assert.That(PatchColorError.WhitePatchIndex(PatchColorError.ToArray(m)), Is.EqualTo(12)); + } + } + + [Test] + public void WhitePatchIndex_null_ArgumentNullException() { + Assert.Throws((Action)(() => PatchColorError.WhitePatchIndex(null))); + } + + [Test] + public void WhitePatchIndex_列が3でない_ArgumentException() { + Assert.Throws((Action)(() => PatchColorError.WhitePatchIndex(new double[2, 2]))); + } + + [Test] + public void WhitePatchIndex_0行_ArgumentException() { + Assert.Throws((Action)(() => PatchColorError.WhitePatchIndex(new double[0, 3]))); + } + + [Test] + public void WhitePatchIndex_NaNを含む_ArgumentException() { + var reference = new double[,] { { 100, 100, 100 }, { double.NaN, 0, 0 } }; + Assert.Throws((Action)(() => PatchColorError.WhitePatchIndex(reference))); + } + + // ================================================================ + // PatchMaxAbs・MaxAbsExcluding + // ================================================================ + + // 色票ごとの差の最大: 2(0 番)・24.65(1 番=白色票に見立てる)・3.47(2 番) + private static readonly double[,] Ref3 = { { 100, 100, 100 }, { 221.5, 220.1, 228.5 }, { 50, 60, 70 } }; + private static readonly double[,] Meas3 = { { 101, 98, 100 }, { 196.85, 212.2, 211.9 }, { 50, 63.47, 69 } }; + + [Test] + public void PatchMaxAbs_その色票のチャネルごとの差の最大() { + Assert.That(PatchColorError.PatchMaxAbs(Meas3, Ref3, 1), Is.EqualTo(24.65).Within(1e-9)); + } + + [Test] + public void PatchMaxAbs_一致_0() { + Assert.That(PatchColorError.PatchMaxAbs(Ref3, Ref3, 0), Is.EqualTo(0.0)); + } + + [Test] + public void PatchMaxAbs_NaNを含む_NaN() { + var measured = new double[,] { { double.NaN, 0, 0 } }; + Assert.That(PatchColorError.PatchMaxAbs(measured, new double[,] { { 0, 0, 100 } }, 0), Is.NaN); + } + + [TestCase(-1)] + [TestCase(3)] + public void PatchMaxAbs_番号が範囲外_ArgumentOutOfRangeException(int patch) { + Assert.Throws((Action)(() => PatchColorError.PatchMaxAbs(Meas3, Ref3, patch))); + } + + [Test] + public void PatchMaxAbs_行数が違う_ArgumentException() { + Assert.Throws((Action)(() => PatchColorError.PatchMaxAbs(new double[2, 3], new double[3, 3], 0))); + } + + [Test] + public void MaxAbsExcluding_白色票を除いた最大() { + Assert.That(PatchColorError.MaxAbsExcluding(Meas3, Ref3, 1, out _), Is.EqualTo(3.47).Within(1e-9)); + } + + [Test] + public void MaxAbsExcluding_白色票を除いた最大の色票() { + PatchColorError.MaxAbsExcluding(Meas3, Ref3, 1, out var maxPatch); + Assert.That(maxPatch, Is.EqualTo(2)); + } + + [TestCase(-1)] + [TestCase(3)] + public void MaxAbsExcluding_範囲外の除外指定_何も除かない(int excluded) { + Assert.That(PatchColorError.MaxAbsExcluding(Meas3, Ref3, excluded, out _), Is.EqualTo(24.65).Within(1e-9)); + } + + [Test] + public void MaxAbsExcluding_範囲外の除外指定_最大の色票は全体の最大() { + PatchColorError.MaxAbsExcluding(Meas3, Ref3, -1, out var maxPatch); + Assert.That(maxPatch, Is.EqualTo(1)); + } + + [Test] + public void MaxAbsExcluding_除外の結果残りが無い_NaN() { + Assert.That(PatchColorError.MaxAbsExcluding(new double[,] { { 1, 2, 3 } }, new double[,] { { 0, 0, 0 } }, 0, out _), Is.NaN); + } + + [Test] + public void MaxAbsExcluding_除外の結果残りが無い_最大の色票はマイナス1() { + PatchColorError.MaxAbsExcluding(new double[,] { { 1, 2, 3 } }, new double[,] { { 0, 0, 0 } }, 0, out var maxPatch); + Assert.That(maxPatch, Is.EqualTo(-1)); + } + + [Test] + public void MaxAbsExcluding_残りにNaN_NaN() { + var measured = new double[,] { { 101, 98, 100 }, { 0, 0, 0 }, { double.NaN, 60, 70 } }; + Assert.That(PatchColorError.MaxAbsExcluding(measured, Ref3, 1, out _), Is.NaN); + } + + [Test] + public void MaxAbsExcluding_除いた色票にだけNaN_NaNにならない() { + var measured = new double[,] { { 101, 98, 100 }, { double.NaN, 0, 0 }, { 50, 63.47, 69 } }; + Assert.That(PatchColorError.MaxAbsExcluding(measured, Ref3, 1, out _), Is.EqualTo(3.47).Within(1e-9)); + } + + [Test] + public void MaxAbsExcluding_除いた色票が全体の最大_白色票と白色票以外の最大の組がCompareの最大と一致する() { + // 白色票を除いた最大と白色票の最大の大きい方 = 全体の最大 + var whole = PatchColorError.Compare(Meas3, Ref3).MaxAbs; + var other = PatchColorError.MaxAbsExcluding(Meas3, Ref3, 1, out _); + var white = PatchColorError.PatchMaxAbs(Meas3, Ref3, 1); + Assert.That(Math.Max(other, white), Is.EqualTo(whole).Within(1e-9)); + } + + [Test] + public void MaxAbsExcluding_形状が違う_ArgumentException() { + Assert.Throws((Action)(() => PatchColorError.MaxAbsExcluding(new double[2, 3], new double[2, 4], 0, out _))); + } + + [Test] + public void MaxAbsExcluding_null_ArgumentNullException() { + Assert.Throws((Action)(() => PatchColorError.MaxAbsExcluding(null, Ref3, 0, out _))); + } + } +} +#endif diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index f745aa4..a956ce0 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -101,6 +101,7 @@ + diff --git a/TIASshot/Cameras/CameraBase.HwTest.cs b/TIASshot/Cameras/CameraBase.HwTest.cs new file mode 100644 index 0000000..95a4872 --- /dev/null +++ b/TIASshot/Cameras/CameraBase.HwTest.cs @@ -0,0 +1,152 @@ +#if HWTEST +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenCvSharp; + +namespace TIASshot { + + /// + /// CameraBase の実機テスト用の観測口(HWTEST 限定.本番・SELFTEST のみのビルドには含まれない). + /// 校正の各段階の時刻(最初のフレーム・チャート検出・校正完了)と,校正後のフレーム・チャートマスクの複製を取る. + /// 本番の校正・撮影の処理の流れや値は変えない(読むだけ.複製は要求があるときだけ作る). + /// + internal abstract partial class CameraBase { + + // 校正の各段階の時刻(Stopwatch.GetTimestamp の値.0 = まだ).コールバックスレッドが書き,UI スレッドが読む + private long _hwTestFirstFrameAt = 0; + private long _hwTestChartDetectedAt = 0; + private long _hwTestCalibratedAt = 0; + + // 校正完了時の保存フォルダ(校正データ.CalcTcc の SetSaveFolder("校正") の結果) + private string _hwTestCalibrationFolder = null; + + // 校正後のフレームの複製の要求と結果(_hwTestCaptureLock で保護) + private readonly object _hwTestCaptureLock = new object(); + private bool _hwTestFrameRequested = false; + private Mat _hwTestCapturedFrame = null; + private List _hwTestCapturedMasks = null; + + /// このカメラのインスタンスで最初に校正処理(ProcessCalibrationFrame)にフレームが届いた時刻(Stopwatch のタイムスタンプ.0 = まだ) + internal long HwTestFirstFrameAt => Interlocked.Read(ref _hwTestFirstFrameAt); + + /// チャートを検出して校正(ホワイトバランスの調整)を始めた時刻(最後の検出.0 = まだ) + internal long HwTestChartDetectedAt => Interlocked.Read(ref _hwTestChartDetectedAt); + + /// 校正(変換行列の算出)が完了した時刻(0 = まだ) + internal long HwTestCalibratedAt => Interlocked.Read(ref _hwTestCalibratedAt); + + /// 校正データの保存フォルダ(校正完了前は null) + internal string HwTestCalibrationFolder => Volatile.Read(ref _hwTestCalibrationFolder); + + /// 直近の保存フォルダ(撮影の後は撮影データのフォルダ.UI スレッドの撮影処理が書く) + internal string HwTestSaveFolder => _saveFolder; + + /// + /// 保存スレッド(SaveThread.まだ起動していなければ null.終了済みのこともある). + /// タイムアウト時にウォッチドッグがスタックを取るためだけに使う(スレッドの操作はしない) + /// + internal Thread HwTestSaveThread => Volatile.Read(ref _saveThread); + + /// 変換行列の項目数の一覧(ColorCorrector の Calib/ConversionChannels の解釈そのもの) + internal IReadOnlyList HwTestConversionChannels => _colorCorrector.Channels.ToArray(); + + /// + /// 校正済みの状態で次に届くフレームとチャートマスクの複製を要求する(それまでの複製は解放する). + /// 複製はコールバックスレッドで作る(チャートマスクはコールバックスレッドだけが読み書きするため). + /// + internal void HwTestRequestCalibratedFrame() { + lock (_hwTestCaptureLock) { + ReleaseHwTestCapture(); + _hwTestFrameRequested = true; + } + } + + /// + /// 要求したフレームとチャートマスクの複製を取り出す(所有権は呼び出し側に移る.必ず Dispose すること). + /// + /// 複製があれば true(取り出した分はカメラ側から消える) + internal bool HwTestTakeCalibratedFrame(out Mat frame, out List masks) { + lock (_hwTestCaptureLock) { + frame = _hwTestCapturedFrame; + masks = _hwTestCapturedMasks; + _hwTestCapturedFrame = null; + _hwTestCapturedMasks = null; + return frame != null; + } + } + + /// + /// 校正で得た変換行列でフレームを sRGB に変換する(撮影の保存 SaveImages と同じ ColorCorrector.ConvertImage.入力は変えない). + /// + /// 変換するフレーム(BGR 8bit.呼び出し側が所有) + /// 変換行列の項目数 + /// 変換後の 8bit 画像(呼び出し側が Dispose する) + internal Mat HwTestConvertToSrgb(Mat frame, int channel) { + // ConvertImage は入力を in-place で CV_64FC3 に変えるため複製を渡す(SaveImages と同じ) + using (var clone = frame.Clone()) { + return _colorCorrector.ConvertImage(clone, channel); + } + } + + /// + /// 画素の値(BGR 8bit 階調の並び.長さは 3 の倍数)を校正で得た変換行列で sRGB に変換し,8bit に切り詰める前の値を返す + /// (HwTestConvertToSrgb と同じ計算で,最後の CV_8UC3 への変換だけを行わない.白飛びの調査用). + /// + /// 変換する画素の値(B0, G0, R0, B1, G1, R1, ...) + /// 変換行列の項目数 + /// 変換後の値(同じ並び.BGR) + internal double[] HwTestConvertPixelsUnclipped(double[] bgr, int channel) { + var n = bgr.Length / 3; + var result = new double[n * 3]; + if (n == 0) return result; + using (var pixels = new Mat(n, 1, MatType.CV_64FC3)) { + for (var i = 0; i < n; i++) { + pixels.At(i, 0) = new Vec3d(bgr[i * 3], bgr[i * 3 + 1], bgr[i * 3 + 2]); + } + using (var converted = _colorCorrector.HwTestConvertPixelsUnclipped(pixels, channel)) { + for (var i = 0; i < n; i++) { + for (var j = 0; j < 3; j++) result[i * 3 + j] = converted.At(i, j); + } + } + } + return result; + } + + /// + /// ProcessCalibrationFrame(コールバックスレッド)の先頭から呼ぶ観測. + /// 最初のフレームの時刻を記録し,校正済みでフレームの複製が要求されていれば,フレームとチャートマスクを複製する. + /// + private void HwTestObserveFrame(Mat imgt) { + Interlocked.CompareExchange(ref _hwTestFirstFrameAt, Stopwatch.GetTimestamp(), 0); + lock (_hwTestCaptureLock) { + if (!_hwTestFrameRequested || !_calibrated || _chartMasks.Count == 0) return; + _hwTestCapturedFrame = imgt.Clone(); + _hwTestCapturedMasks = _chartMasks.Select(m => m.Clone()).ToList(); + _hwTestFrameRequested = false; + } + } + + /// チャートを検出して校正を始めたことの記録(DetectChart から呼ぶ) + private void HwTestMarkChartDetected() { + Interlocked.Exchange(ref _hwTestChartDetectedAt, Stopwatch.GetTimestamp()); + } + + /// 校正の完了の記録(CalcTcc の最後から呼ぶ.このときの保存フォルダが校正データのフォルダ) + private void HwTestMarkCalibrated() { + Volatile.Write(ref _hwTestCalibrationFolder, _saveFolder); + Interlocked.Exchange(ref _hwTestCalibratedAt, Stopwatch.GetTimestamp()); + } + + private void ReleaseHwTestCapture() { + _hwTestCapturedFrame?.Dispose(); + _hwTestCapturedFrame = null; + if (_hwTestCapturedMasks != null) { + foreach (var m in _hwTestCapturedMasks) m?.Dispose(); + _hwTestCapturedMasks = null; + } + } + } +} +#endif diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index b6ff5fa..3bf0b1a 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -17,7 +17,7 @@ using OpenCvSharp; namespace TIASshot { - internal abstract class CameraBase { + internal abstract partial class CameraBase { // インターフェース public abstract bool Connect(); public abstract void Disconnect(); @@ -465,6 +465,8 @@ /// 処理対象 Mat(前処理済み) protected void ProcessCalibrationFrame(Mat imgt) { #if HWTEST + // 実機テストの観測(最初のフレームの時刻・校正後のフレームの複製.要求があるときだけ.CameraBase.HwTest.cs) + HwTestObserveFrame(imgt); // 実機テストのチャート不要の検査では校正を走らせない(本番・SELFTEST のみのビルドには含まれない) if (HwTestCalibrationSuppressed) return; #endif @@ -750,6 +752,9 @@ ReleaseChartMasks(); _chartMasks.AddRange(masks); _calibrating = Config.GetInt("Calib/Frames"); +#if HWTEST + HwTestMarkChartDetected(); +#endif #if MEMMONITOR PreviewProfiler.MarkEvent("CALIB_START"); #endif @@ -808,6 +813,9 @@ _form.ShowMessage(FormatCalibrationDone(imagesSaved)); _form.EnableShots(); _calibrated = true; +#if HWTEST + HwTestMarkCalibrated(); +#endif _lastCalibrationError = null; // 次に失敗したときは改めて通知する _lastCalibrationFailureReason = null; // チャート検出の表示を従来どおりに戻す } diff --git a/TIASshot/ColorCorrection/ColorCorrector.HwTest.cs b/TIASshot/ColorCorrection/ColorCorrector.HwTest.cs new file mode 100644 index 0000000..4ed56b9 --- /dev/null +++ b/TIASshot/ColorCorrection/ColorCorrector.HwTest.cs @@ -0,0 +1,25 @@ +#if HWTEST +using OpenCvSharp; + +namespace TIASshot { + + /// + /// ColorCorrector の実機テスト用の観測口(HWTEST 限定.本番・SELFTEST のみのビルドには含まれない). + /// 本番の変換(ConvertImage・ConvertColor)の処理や値は変えない(読むだけ). + /// + internal partial class ColorCorrector { + + /// + /// 画素の並び(n×1 の CV_64FC3.BGR)を校正で得た変換行列で sRGB に変換し,8bit に切り詰める前の値(n×3 の CV_64FC1.BGR)を返す. + /// ConvertImage(src, channel) と同じ計算(ExtendMat → 変換行列との積)で,最後の CV_8UC3 への変換(四捨五入と 0〜255 への飽和)だけを行わない. + /// 白飛び(255 を超えた値)の調査用. + /// + /// 画素の並び(n×1 の CV_64FC3.呼び出し側が所有) + /// 変換行列の項目数(校正済みであること) + /// 変換後の値(n×3.呼び出し側が Dispose する) + internal Mat HwTestConvertPixelsUnclipped(Mat pixelsBgr, int channel) { + return ConvertColor(pixelsBgr, _convRGB2SRGB[channel]); + } + } +} +#endif diff --git a/TIASshot/ColorCorrection/ColorCorrector.cs b/TIASshot/ColorCorrection/ColorCorrector.cs index de4458b..34cbe12 100644 --- a/TIASshot/ColorCorrection/ColorCorrector.cs +++ b/TIASshot/ColorCorrection/ColorCorrector.cs @@ -11,7 +11,7 @@ /// 色補正処理(TCC 変換行列の計算・色変換)を担うクラス. /// CameraBase から委譲される.チャート検出・ファイル I/O は呼び出し側に委ねる. /// - internal class ColorCorrector { + internal partial class ColorCorrector { // 参照値(変換目標) readonly Mat TCC_SRGB; diff --git a/TIASshot/HwTest/HwTestJudge.cs b/TIASshot/HwTest/HwTestJudge.cs index 7f30d50..bb56641 100644 --- a/TIASshot/HwTest/HwTestJudge.cs +++ b/TIASshot/HwTest/HwTestJudge.cs @@ -1,6 +1,7 @@ #if HWTEST using System; using System.Collections.Generic; +using System.Linq; namespace TIASshot { @@ -124,8 +125,149 @@ public static readonly TimeSpan ReconnectTimeout = TimeSpan.FromSeconds(60); public static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(30); - /// 実機テスト全体の上限(各項目の上限の合計より長くする) + /// 実機テスト全体の上限(チャート不要の検査.各項目の上限の合計より長くする) public static readonly TimeSpan OverallTimeout = TimeSpan.FromMinutes(10); + + // ==== チャートが必要な検査(--with-chart.PLAN_06 フェーズ 2).すべて暫定(最初の実機実行で決め直す) ==== + + /// calibrate: 接続から自動校正の完了(変換行列の算出)までの待機の上限(暫定.2026-10-08 の実機の実測に合わせて 120 秒から縮めた) + public static readonly TimeSpan CalibrationWait = TimeSpan.FromSeconds(60); + + /// calibrate: 校正の完了から撮影ボタンが有効になるまでの待機の上限(EnableShots は UI スレッドへの投稿) + public static readonly TimeSpan ShotsEnabledWait = TimeSpan.FromSeconds(5); + + /// calibrate: 校正後のフレームの複製が届くまでの待機の上限 + public static readonly TimeSpan CalibratedFrameWait = TimeSpan.FromSeconds(15); + + /// + /// calibrate: 校正後のフレームを変換した色票と参照値(tcc_srgb)の差の絶対値の平均の上限(8bit 階調.暫定). + /// セルフテスト(合成チャート・JPEG)の上限は 6.2026-10-08 の実機の実測(全次元で平均 1.57 以下)に合わせて 10 から縮めた + /// + public const double MaxCalibrationMeanError = 5.0; + + /// + /// calibrate: 同じく白色票を除いた色票の差の絶対値の最大の上限(8bit 階調.暫定.セルフテストは 15). + /// 2026-10-08 の実機の実測(白色票以外は 3.5 以下)に合わせた + /// + public const double MaxCalibrationMaxError = 10.0; + + /// + /// calibrate: 白色票(参照値から PatchColorError.WhitePatchIndex で決める.tcc_srgb.csv では 13 番)の差の絶対値の最大の上限(8bit 階調). + /// 【暫定】17 次元の変換で白色票が 255 に頭打ちになる(白飛び)疑いがあり(2026-10-08 の実測 27.35),その調査が済むまでの値 + /// + public const double MaxCalibrationWhiteMaxError = 40.0; + + /// shot: 連続撮影の枚数(暫定) + public const int ShotMultiCount = 3; + + /// shot: 連続撮影の間隔(ms)(暫定) + public const int ShotMultiIntervalMs = 1000; + + /// shot: 連続撮影の撮影時刻の間隔と設定値の差の許容(ms)(暫定.2026-10-08 の実機の実測に合わせて 200 ms から縮めた) + public const double ShotIntervalToleranceMs = 100.0; + + /// shot・close-saving: 撮影から保存(保存スレッドの終了)までの待機の上限(2026-10-09 に 90 秒から延ばした.重いアプリが動いていると保存が 1 枚あたり約 11 秒に遅くなり,close-saving の 10 枚の再試行で 90 秒を超えうるため) + public static readonly TimeSpan SaveWait = TimeSpan.FromSeconds(180); + + /// shot・close-saving: 保存の完了後,保存スレッドからの投稿(ShowMessage・EnableShots)を処理させる時間 + public static readonly TimeSpan AfterSaveSettle = TimeSpan.FromMilliseconds(500); + + /// close-saving: 保存中に閉じる検査で撮る連続撮影の枚数(間隔 0.保存に時間がかかるようにする)(暫定) + public const int CloseSavingShotCount = 5; + + /// close-saving: 保存中の状態を作れなかったときに 1 回だけ再試行する連続撮影の枚数(暫定) + public const int CloseSavingRetryShotCount = 10; + + /// close-saving: 保存中の状態を作る試行の回数の上限(最初の 1 回 + 再試行 1 回) + public const int CloseSavingMaxAttempts = 2; + + /// close-calibrating: 再接続から校正中(チャートを検出してホワイトバランスを調整中)になるまでの待機の上限(暫定) + public static readonly TimeSpan CalibratingWait = TimeSpan.FromSeconds(60); + + /// close-calibrating: 校正中になってから閉じる処理を呼ぶまでの時間(校正の途中で閉じるため.Calib/Frames 100 枚・約 15 fps で約 7 秒続く) + public static readonly TimeSpan CalibratingBeforeClose = TimeSpan.FromMilliseconds(500); + + public static readonly TimeSpan CalibrateTimeout = TimeSpan.FromSeconds(180); + public static readonly TimeSpan ShotTimeout = TimeSpan.FromSeconds(240); + public static readonly TimeSpan CloseSavingTimeout = TimeSpan.FromSeconds(300); + public static readonly TimeSpan CloseCalibratingTimeout = TimeSpan.FromSeconds(150); + + /// 実機テスト全体の上限(チャートが必要な検査.各項目の上限の合計(約 16 分)より長くする) + public static readonly TimeSpan OverallTimeoutWithChart = TimeSpan.FromMinutes(20); + + // ==== タイムアウト時の記録(止まった場所の調査用.判定には使わない) ==== + + /// タイムアウト時: 1 スレッドのマネージドスタックの取得の上限(Suspend が戻らない等でウォッチドッグ自体が止まらないため) + public static readonly TimeSpan HangStackTimeout = TimeSpan.FromSeconds(10); + + /// + /// タイムアウト時: ミニダンプの書き出しの上限.全メモリのダンプ(1〜3 GB 程度)を SSD に書く時間に余裕を持たせつつ, + /// ランナー(run_hwtest.py)の打ち切り(全体の上限 + 120 秒)より十分短くする + /// + public static readonly TimeSpan HangDumpTimeout = TimeSpan.FromSeconds(60); + + /// 全メモリのダンプにするプライベートメモリの上限(バイト).これを超えたら小さいダンプにする(ダンプが数 GB を超えないため) + public const long FullDumpMaxPrivateBytes = 3L * 1024 * 1024 * 1024; + + /// 全メモリのダンプに必要な空き容量の余裕(バイト).ダンプの大きさはプライベートメモリ+読み込んだモジュールの分になるため,その分を見込む + public const long FullDumpDiskMargin = 1L * 1024 * 1024 * 1024; + + /// 小さいダンプ(スレッド・スタックと参照先のメモリのみ.数十 MB)を書くのに必要な空き容量(バイト) + public const long SmallDumpMinFreeBytes = 256L * 1024 * 1024; + } + + /// + /// 色票内の画素の値の集計(HWTEST 限定.HwTestJudge.SummarizePatchPixels の結果). + /// 配列はチャネルの順(0 = B・1 = G・2 = R).画素が無い場合は値がすべて NaN. + /// NaN の画素は平均を NaN にする(最小・最大・割合には数えない). + /// + internal sealed class HwTestPatchPixelStats { + /// 画素の数 + public int Pixels; + /// チャネルごとの平均 + public readonly double[] Mean = new double[3]; + /// チャネルごとの最小 + public readonly double[] Min = new double[3]; + /// チャネルごとの最大 + public readonly double[] Max = new double[3]; + /// いずれかのチャネルが 255 以上の画素の割合(8bit 画像では 255 に頭打ちになった画素=白飛び) + public double FractionAnyAtOrAboveMax; + /// いずれかのチャネルが 0 以下の画素の割合(8bit 画像では 0 に頭打ちになった画素=黒つぶれ) + public double FractionAnyAtOrBelowMin; + /// いずれかのチャネルが 255 を超えた画素の割合(8bit に切り詰める前の値で意味がある.8bit 画像では常に 0) + public double FractionAnyAboveMax; + /// いずれかのチャネルが 0 未満の画素の割合(8bit に切り詰める前の値で意味がある.8bit 画像では常に 0) + public double FractionAnyBelowMin; + } + + /// 撮影ログ(Info.csv)の撮影の 1 行(HWTEST 限定) + internal struct HwTestShotLogRow { + /// 撮影の番号(1 始まり) + public int Shot; + /// 撮影開始からの時刻(ms) + public long TimeMs; + /// 備考(撮影の中止の理由等.無ければ空) + public string Note; + } + + /// 保存中に閉じる検査(close-saving)で保存中の状態を作れたかの判断(HWTEST 限定) + internal enum HwTestSavingAttempt { + /// 保存中の状態を作れた(閉じる処理の取り消しを検査する) + Ready, + /// 作れなかったので枚数を増やしてもう 1 回試す + Retry, + /// 作れなかった(試行の上限).WARN にして取り消しの検査を省く + GiveUp, + } + + /// タイムアウト時に書くミニダンプの種類(HWTEST 限定.HwTestJudge.ChooseDumpKind の結果) + internal enum HwTestDumpKind { + /// 書かない(空き容量が足りない) + None, + /// 小さいダンプ(スレッド・スタックと参照先のメモリ.マネージドのヒープは含まないため SOS の !clrstack は不完全になりうる) + Small, + /// 全メモリのダンプ(マネージドのスタック・ヒープを後から Visual Studio・WinDbg(SOS)で見られる) + Full, } /// ウォッチドッグの判定結果(HWTEST 限定) @@ -704,6 +846,301 @@ return exitCode == HwTestExitCodes.Fail || exitCode == HwTestExitCodes.Timeout; } + // ================================================================================ + // タイムアウト時の記録(スタック・ミニダンプ) + // ================================================================================ + + /// + /// タイムアウト時のミニダンプの保存先(作業フォルダ直下の hang-<日時>.dmp). + /// 作業フォルダはタイムアウト時に残る(ShouldKeepWorkDir)ため,ダンプも一緒に残る. + /// + /// 作業フォルダ(%TEMP%\TIASshot-hwtest\work-*) + /// タイムアウトを検知した時刻 + public static string HangDumpPath(string workDir, DateTime now) { + return System.IO.Path.Combine(workDir, $"hang-{now:yyyyMMdd-HHmmss}.dmp"); + } + + /// + /// タイムアウト時に書くミニダンプの種類.マネージドのスタックを後から見られるよう全メモリのダンプを優先する. + /// プライベートメモリが FullDumpMaxPrivateBytes を超える(ダンプが数 GB を超える)か, + /// 空き容量が「プライベートメモリ+ FullDumpDiskMargin」に満たない場合は小さいダンプにし, + /// 小さいダンプの分(SmallDumpMinFreeBytes)も空いていなければ書かない. + /// プライベートメモリが分からない(負)場合は大きさを見積もれないため小さいダンプにする. + /// + /// 自プロセスのプライベートメモリ(バイト.不明なら負) + /// 保存先のドライブの空き容量(バイト.不明なら負) + public static HwTestDumpKind ChooseDumpKind(long privateBytes, long freeDiskBytes) { + if (freeDiskBytes < HwTestThresholds.SmallDumpMinFreeBytes) return HwTestDumpKind.None; + if (privateBytes < 0 || privateBytes > HwTestThresholds.FullDumpMaxPrivateBytes) return HwTestDumpKind.Small; + // 加算のあふれは privateBytes の上限(3 GB)で起きない + if (freeDiskBytes < privateBytes + HwTestThresholds.FullDumpDiskMargin) return HwTestDumpKind.Small; + return HwTestDumpKind.Full; + } + + /// + /// ミニダンプの種類に対応する MINIDUMP_TYPE のフラグ(dbghelp.h の値). + /// Full: MiniDumpWithFullMemory(0x2) | WithHandleData(0x4) | WithUnloadedModules(0x20) | WithFullMemoryInfo(0x800) | WithThreadInfo(0x1000) + /// (procdump -ma と同じ組み合わせ.マネージドのスタック・ヒープを SOS・Visual Studio で解析するには全メモリが要る). + /// Small: WithDataSegs(0x1) | WithHandleData(0x4) | WithUnloadedModules(0x20) | WithIndirectlyReferencedMemory(0x40) | WithProcessThreadData(0x100) | WithThreadInfo(0x1000) + /// (ネイティブのスタックと,スタックから参照されるメモリ.マネージドのスタックは結果ファイルの文字列で補う). + /// None は 0(呼び出し側は書かない). + /// + public static uint DumpTypeFlags(HwTestDumpKind kind) { + switch (kind) { + case HwTestDumpKind.Full: return 0x2 | 0x4 | 0x20 | 0x800 | 0x1000; + case HwTestDumpKind.Small: return 0x1 | 0x4 | 0x20 | 0x40 | 0x100 | 0x1000; + default: return 0; + } + } + + // ================================================================================ + // チャートが必要な検査(--with-chart) + // ================================================================================ + + /// + /// 実行する検査の ID の一覧(結果ファイルの順序).チャート不要の検査と,チャートが必要な検査(--with-chart)は別に実行する + /// (校正が走ると露光・ゲイン・光源の測定が乱れるため.connect と cleanup は共通). + /// + public static string[] CheckIds(bool withChart) { + return withChart + ? new[] { "connect", "calibrate", "shot", "close-saving", "close-calibrating", "cleanup" } + : new[] { "connect", "preview", "exposure-gain", "light", "close-preview", "reconnect", "cleanup" }; + } + + /// 実機テスト全体の上限(チャートが必要な検査は校正・保存を待つため長い) + public static TimeSpan OverallTimeout(bool withChart) { + return withChart ? HwTestThresholds.OverallTimeoutWithChart : HwTestThresholds.OverallTimeout; + } + + /// + /// 2 つの時刻(Stopwatch のタイムスタンプ)の差(ms).どちらかが 0(まだ記録されていない)・逆順・frequency が 0 以下なら NaN. + /// + public static double TimestampDeltaMs(long from, long to, long frequency) { + if (from == 0 || to == 0 || to < from || frequency <= 0) return double.NaN; + return (to - from) * 1000.0 / frequency; + } + + /// + /// 校正後のフレームを変換した色票の誤差が上限内か. + /// 差の絶対値の平均 ≦ maxMean かつ 白色票を除いた最大 ≦ maxOtherMax かつ 白色票の最大 ≦ maxWhiteMax. + /// どれかが NaN(測定できない)なら false. + /// + /// 全色票 × BGR の差の絶対値の平均(PatchColorErrorStats.MeanAbs) + /// 白色票を除いた差の絶対値の最大(PatchColorError.MaxAbsExcluding) + /// 白色票の差の絶対値の最大(PatchColorError.PatchMaxAbs) + public static bool JudgeColorError(double meanAbs, double otherMaxAbs, double whiteMaxAbs, double maxMean, double maxOtherMax, double maxWhiteMax) { + if (double.IsNaN(meanAbs) || double.IsNaN(otherMaxAbs) || double.IsNaN(whiteMaxAbs)) return false; + return meanAbs <= maxMean && otherMaxAbs <= maxOtherMax && whiteMaxAbs <= maxWhiteMax; + } + + /// sRGB 8bit 階調の下端(これ以下は黒つぶれ) + public const double Srgb8Min = 0.0; + + /// sRGB 8bit 階調の上端(これ以上は白飛び) + public const double Srgb8Max = 255.0; + + /// + /// 色票内の画素の値(BGR の並び)の集計(白飛び・黒つぶれの調査用.参考値). + /// 8bit 画像(0〜255 に切り詰め済み)でも,8bit に切り詰める前の値(範囲外を含む)でも使う. + /// + /// 画素の値(B0, G0, R0, B1, G1, R1, ....長さは 3 の倍数) + /// bgr が null の場合 + /// 長さが 3 の倍数でない場合 + public static HwTestPatchPixelStats SummarizePatchPixels(IReadOnlyList bgr) { + if (bgr == null) throw new ArgumentNullException(nameof(bgr)); + if (bgr.Count % 3 != 0) throw new ArgumentException($"画素の値の並びの長さが 3 の倍数ではありません: {bgr.Count}", nameof(bgr)); + var n = bgr.Count / 3; + var s = new HwTestPatchPixelStats { Pixels = n }; + if (n == 0) { + for (var j = 0; j < 3; j++) s.Mean[j] = s.Min[j] = s.Max[j] = double.NaN; + s.FractionAnyAtOrAboveMax = s.FractionAnyAtOrBelowMin = s.FractionAnyAboveMax = s.FractionAnyBelowMin = double.NaN; + return s; + } + var sum = new double[3]; + for (var j = 0; j < 3; j++) { + s.Min[j] = double.PositiveInfinity; + s.Max[j] = double.NegativeInfinity; + } + int atOrAbove = 0, atOrBelow = 0, above = 0, below = 0; + for (var i = 0; i < n; i++) { + bool anyAtOrAbove = false, anyAtOrBelow = false, anyAbove = false, anyBelow = false; + for (var j = 0; j < 3; j++) { + var v = bgr[i * 3 + j]; + sum[j] += v; + if (v < s.Min[j]) s.Min[j] = v; + if (v > s.Max[j]) s.Max[j] = v; + if (v >= Srgb8Max) anyAtOrAbove = true; + if (v > Srgb8Max) anyAbove = true; + if (v <= Srgb8Min) anyAtOrBelow = true; + if (v < Srgb8Min) anyBelow = true; + } + if (anyAtOrAbove) atOrAbove++; + if (anyAtOrBelow) atOrBelow++; + if (anyAbove) above++; + if (anyBelow) below++; + } + for (var j = 0; j < 3; j++) s.Mean[j] = sum[j] / n; + s.FractionAnyAtOrAboveMax = (double)atOrAbove / n; + s.FractionAnyAtOrBelowMin = (double)atOrBelow / n; + s.FractionAnyAboveMax = (double)above / n; + s.FractionAnyBelowMin = (double)below / n; + return s; + } + + /// + /// 撮影ログ(Info.csv)の行から撮影の行を取り出す.見出し行「Shot,Time(ms),Note」の後の各行を + /// 「番号,時刻(ms)[,備考]」として読む(備考は RFC 4180 のエスケープのまま残す).空行は飛ばす. + /// + /// Info.csv の全行 + /// 読めない場合の理由(見出し行が無い・数値でない行).読めたら null + /// 撮影の行(読めない場合も読めた分までを返す) + public static List ParseShotLog(IReadOnlyList lines, out string error) { + error = null; + var rows = new List(); + if (lines == null) { + error = "撮影ログがありません"; + return rows; + } + var header = -1; + for (var i = 0; i < lines.Count; i++) { + if (lines[i] == "Shot,Time(ms),Note") { + header = i; + break; + } + } + if (header < 0) { + error = "見出し行「Shot,Time(ms),Note」がありません"; + return rows; + } + var inv = System.Globalization.CultureInfo.InvariantCulture; + for (var i = header + 1; i < lines.Count; i++) { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + var parts = line.Split(new[] { ',' }, 3); + int shot; + long time; + if (parts.Length < 2 + || !int.TryParse(parts[0], System.Globalization.NumberStyles.Integer, inv, out shot) + || !long.TryParse(parts[1], System.Globalization.NumberStyles.Integer, inv, out time)) { + error = $"{i + 1} 行目を撮影の行として読めません: {line}"; + return rows; + } + rows.Add(new HwTestShotLogRow { Shot = shot, TimeMs = time, Note = parts.Length > 2 ? parts[2] : "" }); + } + return rows; + } + + /// + /// 撮影時刻の間隔と設定値の差の絶対値の最大(ms).時刻が 2 つ未満なら NaN(間隔が無い). + /// + public static double MaxIntervalDeviationMs(IReadOnlyList timesMs, int intervalMs) { + if (timesMs == null || timesMs.Count < 2) return double.NaN; + double max = 0; + for (var i = 1; i < timesMs.Count; i++) { + var dev = Math.Abs((timesMs[i] - timesMs[i - 1]) - (double)intervalMs); + if (dev > max) max = dev; + } + return max; + } + + /// + /// 1 回の撮影で保存されるはずのファイル名の一覧(撮影ログ Info.csv・撮影画像 Shot{NO}・変換行列の項目数ごとの sRGB 変換画像 sRGB{CN}_{NO}). + /// 名前の組み立ては CameraBase.SaveImages と同じ({NO} は 4 桁の 1 始まり,{CN} は 2 桁). + /// + /// File/RgbImage(例 Shot{NO}.png) + /// File/SrgbImage(例 sRGB{CN}_{NO}.jpg) + /// File/Info(例 Info.csv) + /// 変換行列の項目数の一覧(null は無し) + /// 撮影枚数 + /// 名前の書式が null の場合 + public static List ExpectedShotFiles(string rgbPattern, string srgbPattern, string infoName, IEnumerable channels, int count) { + if (rgbPattern == null) throw new ArgumentNullException(nameof(rgbPattern)); + if (srgbPattern == null) throw new ArgumentNullException(nameof(srgbPattern)); + if (infoName == null) throw new ArgumentNullException(nameof(infoName)); + var names = new List { infoName }; + var channelList = channels?.ToList() ?? new List(); + for (var i = 0; i < count; i++) { + var no = $"{i + 1:0000}"; + names.Add(rgbPattern.Replace("{NO}", no)); + foreach (var ch in channelList) { + names.Add(srgbPattern.Replace("{CN}", $"{ch:00}").Replace("{NO}", no)); + } + } + return names; + } + + /// + /// 期待したファイル名と実際のファイル名を比べ,足りないもの(missing)と余分なもの(unexpected)を返す(大文字小文字は区別しない.名前順). + /// + public static void CompareFileNames(IEnumerable expected, IEnumerable actual, out List missing, out List unexpected) { + var exp = new HashSet(expected ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); + var act = new HashSet(actual ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); + missing = exp.Where(x => !act.Contains(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + unexpected = act.Where(x => !exp.Contains(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// 保存中に閉じる検査で,連続撮影の直後に保存中の状態を作れたかの判断. + /// 作れたら Ready,作れず試行が残っていれば Retry,残っていなければ GiveUp(FAIL ではなく WARN にする). + /// + /// 連続撮影の直後に保存中(CameraBase.IsSaving)だったか + /// 何回目の試行か(1 始まり) + /// 試行の回数の上限 + public static HwTestSavingAttempt DecideSavingAttempt(bool savingObserved, int attempt, int maxAttempts) { + if (savingObserved) return HwTestSavingAttempt.Ready; + return attempt < maxAttempts ? HwTestSavingAttempt.Retry : HwTestSavingAttempt.GiveUp; + } + + /// 保存中に閉じる検査の試行ごとの連続撮影の枚数(1 回目は CloseSavingShotCount,2 回目以降は CloseSavingRetryShotCount) + public static int CloseSavingShotCount(int attempt) { + return attempt <= 1 ? HwTestThresholds.CloseSavingShotCount : HwTestThresholds.CloseSavingRetryShotCount; + } + + /// + /// 保存中に閉じようとしたときに,閉じるのが正しく取り消されたかを調べ,問題点を返す(空なら合格). + /// 期待: 閉じると決まらない(e.Cancel)・表示が expectedMessage・後始末の段階が 1 つも進まない・閉じ始めのフラグが立たない・本モニタの再描画タイマーが動いたまま. + /// + /// 閉じる処理が閉じると決めたか(HwTestRequestClose の戻り値) + /// 閉じる処理の直後のステータス表示 + /// 期待する表示(Form1.SavingOnCloseMessage) + /// 閉じる処理で増えた後始末の段階の数 + /// 閉じ始めのフラグ + /// 本モニタの再描画タイマーが動作中か + public static List CancelledCloseProblems(bool closeAccepted, string shownMessage, string expectedMessage, + int stepsAdded, bool isClosing, bool refreshTimerRunning) { + var problems = new List(); + if (closeAccepted) problems.Add("保存中なのに閉じると決まりました(取り消されていません)"); + if (!string.Equals(shownMessage, expectedMessage, StringComparison.Ordinal)) { + problems.Add($"表示が「{shownMessage}」です(期待「{expectedMessage}」)"); + } + if (stepsAdded != 0) problems.Add($"後始末が {stepsAdded} 段階進みました(保存中は後始末を走らせない)"); + if (isClosing) problems.Add("閉じ始めのフラグが立っています"); + if (!refreshTimerRunning) problems.Add("本モニタの再描画タイマーが止まっています"); + return problems; + } + + /// 閉じる処理の後始末の段階の正しい順序(Form1.HwTestCloseSteps に記録される名前.close-preview・close-saving・close-calibrating 共通) + public static readonly IReadOnlyList CloseSequence = new[] { "closing", "timer", "camera", "light" }; + + /// + /// 閉じる処理の後始末の段階の記録のうち,startIndex 以降が「closing → timer → camera → light」(CloseSequence)の順に過不足なく並んでいるか + /// (記録は閉じる処理のたびに足されていくため,その回の閉じる処理の前の件数を startIndex に渡す). + /// + public static bool IsCloseSequenceSince(IReadOnlyList steps, int startIndex) { + if (steps == null || startIndex < 0 || startIndex > steps.Count) return false; + if (steps.Count - startIndex != CloseSequence.Count) return false; + for (var i = 0; i < CloseSequence.Count; i++) { + if (!string.Equals(steps[startIndex + i], CloseSequence[i], StringComparison.Ordinal)) return false; + } + return true; + } + + /// 後始末の段階の記録のうち startIndex 以降(その回の閉じる処理の分) + public static List StepsSince(IReadOnlyList steps, int startIndex) { + if (steps == null) return new List(); + return steps.Skip(Math.Max(0, startIndex)).ToList(); + } + private static void ValidateRange(double current, double min, double max) { if (!IsFinite(current) || !IsFinite(min) || !IsFinite(max)) { throw new ArgumentException($"設定値・範囲に非有限値が含まれます: {current}({min}〜{max})"); diff --git a/TIASshot/HwTest/HwTestOptions.cs b/TIASshot/HwTest/HwTestOptions.cs index 10c3107..8d46d70 100644 --- a/TIASshot/HwTest/HwTestOptions.cs +++ b/TIASshot/HwTest/HwTestOptions.cs @@ -29,7 +29,7 @@ /// 結果ファイルの出力先(省略時は null.呼び出し側が既定のパスを決める) public string OutPath { get; private set; } - /// チャートが必要な検査も行うか(フェーズ 2.現状は未実装) + /// チャートが必要な検査(calibrate・shot・close-saving・close-calibrating.PLAN_06 フェーズ 2)を行うか.指定するとチャート不要の検査は行わない(connect・cleanup は共通) public bool WithChart { get; private set; } /// 合格時も作業フォルダ(一時設定・保存物)を削除しないか diff --git a/TIASshot/HwTest/HwTestRunner.Chart.cs b/TIASshot/HwTest/HwTestRunner.Chart.cs new file mode 100644 index 0000000..d538e35 --- /dev/null +++ b/TIASshot/HwTest/HwTestRunner.Chart.cs @@ -0,0 +1,575 @@ +#if HWTEST +#if !SELFTEST +#error HWTEST は SELFTEST の部品(SelfTestPump・SelfTestCheck・PatchColorError)を使うため,SELFTEST と同時に定義すること +#endif +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Cv2 = OpenCvSharp.Cv2; +using Mat = OpenCvSharp.Mat; + +namespace TIASshot { + + /// + /// 実機テストのチャートが必要な検査(--with-chart.PLAN_06 フェーズ 2.HWTEST 限定). + /// 前提: カメラの前に舌診チャートがはまっている.校正は止めない(CameraBase.HwTestCalibrationSuppressed は false). + /// 順序: connect → calibrate → shot → close-saving → close-calibrating → cleanup. + /// 保存先は一時フォルダ(Config の File/SaveFolder は作業フォルダ配下に差し替え済み).判定はすべて HwTestThresholds の暫定しきい値と + /// HwTestJudge・PatchColorError の純粋関数で行い,測定値は合否にかかわらず詳細行に出す. + /// + internal static partial class HwTestRunner { + + // ================================================================================ + // calibrate + // ================================================================================ + + /// + /// 接続後のプレビューで自動校正(チャート検出 → ホワイトバランスの調整 → 変換行列の算出)が完了するのを待ち, + /// 所要時間の内訳・撮影ボタンの有効化・校正データ・校正後のフレームを変換した色票の参照値との差を確かめる. + /// + private static void CheckCalibrate(Session session, SelfTestCheck c) { + var form = session.Form; + var camera = session.Camera; + var freq = Stopwatch.Frequency; + + SetStep(session, "最初のフレームの待機"); + var first = SelfTestPump.PumpUntil(() => camera.HwTestFirstFrameAt != 0, HwTestThresholds.FirstFrameTimeout); + if (!c.Expect(first, $"{HwTestThresholds.FirstFrameTimeout.TotalSeconds:0} 秒以内にフレームが届きません(接続後 {form.HwTestFrameCount} フレーム)")) return; + + SetStep(session, "自動校正の完了の待機(チャート検出 → ホワイトバランスの調整 → 変換行列の算出)"); + var messages = new List(); + var done = SelfTestPump.PumpUntil(() => { + RecordDistinctMessage(messages, form.HwTestMessage); + return camera.HwTestIsCalibrated; + }, HwTestThresholds.CalibrationWait); + RecordDistinctMessage(messages, form.HwTestMessage); + + var t0 = camera.HwTestFirstFrameAt; + var tDetected = camera.HwTestChartDetectedAt; + var tCalibrated = camera.HwTestCalibratedAt; + c.Info($"校正の所要時間: 最初のフレーム → チャート検出(固定判定を含む) {FormatMs(HwTestJudge.TimestampDeltaMs(t0, tDetected, freq))}," + + $"チャート検出 → 校正完了(ホワイトバランス {Config.GetInt("Calib/Frames")} フレーム・変換行列の算出・校正データの保存) {FormatMs(HwTestJudge.TimestampDeltaMs(tDetected, tCalibrated, freq))}," + + $"合計 {FormatMs(HwTestJudge.TimestampDeltaMs(t0, tCalibrated, freq))}(上限 暫定 {HwTestThresholds.CalibrationWait.TotalSeconds:0} 秒)"); + c.Info($"校正中の表示(重複を除いた順): {string.Join(" → ", messages.Select(m => $"「{m}」"))}"); + if (!c.Expect(done, + $"{HwTestThresholds.CalibrationWait.TotalSeconds:0} 秒以内に自動校正が完了しません(状態: {(camera.IsCalibrating ? "校正中" : "チャート未検出")}," + + $"{form.HwTestFrameCount} フレーム,最後の表示「{form.HwTestMessage}」.チャートがはまっているか・照明を確認してください)")) return; + + SetStep(session, "撮影ボタンの有効化の待機"); + var enabled = SelfTestPump.PumpUntil(() => form.HwTestShotsEnabled, HwTestThresholds.ShotsEnabledWait); + c.Expect(enabled, "校正完了後に撮影ボタンが有効になりません(CalcTcc → EnableShots)", "校正完了後に撮影ボタンが有効になりました"); + c.Info($"校正完了後の表示: 「{form.HwTestMessage}」"); + + var refPath = Config.GetString("File/TccSrgbRef"); + using (var reference = IoUtil.LoadMatFromCsv(refPath)) { + if (!c.Expect(reference != null && reference.Rows == 24 && reference.Cols == 3, $"参照値({refPath})を 24x3 として読み込めません")) return; + var refArray = PatchColorError.ToArray(reference); + var channels = camera.HwTestConversionChannels; + c.Info($"変換行列の項目数(Calib/ConversionChannels): {string.Join(", ", channels)}"); + + CheckCalibrationData(session, c, camera.HwTestCalibrationFolder, channels, refArray); + CheckCalibratedFrame(session, c, camera, channels, refArray); + } + c.Info("カメラのホワイトバランスは校正で変わっています(次の接続でデバイス状態ファイルの値に戻ります.デバイス状態ファイルには書き込みません)"); + } + + /// + /// 校正データ(一時フォルダの「校正」フォルダ)の有無と,校正に使った色票の値を変換した値(TCC_sRGB{CN}.csv)の参照値との差(当てはめの誤差.参考)を出す. + /// + private static void CheckCalibrationData(Session session, SelfTestCheck c, string folder, IReadOnlyList channels, double[,] refArray) { + if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"校正データのフォルダがありません: {folder}")) return; + c.Expect(SelfTestRunner.IsUnder(folder, session.SaveRoot), $"校正データが一時フォルダの外に保存されました: {folder}", + $"校正データ: {folder}({Directory.GetFiles(folder).Length} ファイル)"); + foreach (var ch in channels) { + var name = Config.GetString("File/TccSrgbValues").Replace("{CN}", $"{ch:00}"); + var path = Path.Combine(folder, name); + using (var fitted = File.Exists(path) ? IoUtil.LoadMatFromCsv(path) : null) { + if (!c.Expect(fitted != null && fitted.Rows == 24 && fitted.Cols == 3, $"{name} を 24x3 として読み込めません: {path}")) continue; + var s = PatchColorError.Compare(PatchColorError.ToArray(fitted), refArray); + c.Info($"{ch} 次元: 校正に使った色票の値の変換値と参照値の差(当てはめの誤差.参考.{name}): {DescribeColorError(s)}"); + } + } + } + + /// + /// 校正後に届いたフレームを校正で得た変換行列で sRGB に変換し(撮影の保存と同じ ColorCorrector.ConvertImage), + /// 校正時のチャートマスクで 24 色票の平均値を取り,参照値(tcc_srgb)との差を変換行列の項目数ごとに判定する. + /// + private static void CheckCalibratedFrame(Session session, SelfTestCheck c, CameraBase camera, IReadOnlyList channels, double[,] refArray) { + SetStep(session, "校正後のフレームの複製の待機"); + Mat frame = null; + List masks = null; + camera.HwTestRequestCalibratedFrame(); + try { + var got = SelfTestPump.PumpUntil(() => camera.HwTestTakeCalibratedFrame(out frame, out masks), HwTestThresholds.CalibratedFrameWait); + if (!c.Expect(got, $"{HwTestThresholds.CalibratedFrameWait.TotalSeconds:0} 秒以内に校正後のフレームが届きません")) return; + if (!c.Expect(masks.Count == 24, $"チャートマスクが 24 個ではありません: {masks.Count} 個")) return; + c.Info($"校正後のフレーム: {frame.Width}x{frame.Height}"); + + // 白色票は参照値(BGR の和が最大の色票)から決める(tcc_srgb.csv の行の並びに依存させない) + var white = PatchColorError.WhitePatchIndex(refArray); + var rawWhite = Cv2.Mean(frame, masks[white]); + c.Info($"変換前の白色票({white + 1} 番.参照値の BGR の和が最大の色票): B{rawWhite.Val0:0.0} G{rawWhite.Val1:0.0} R{rawWhite.Val2:0.0}" + + $"(校正の目標 {Config.GetString("Calib/Reference/B")}/{Config.GetString("Calib/Reference/G")}/{Config.GetString("Calib/Reference/R")}.参考)"); + + // 変換前の色票内の画素(白飛びの調査で 8bit に切り詰める前の値を求めるため.並びは変換後の画素と同じ) + var rawPixels = masks.Select(m => MaskedPixels(frame, m)).ToList(); + c.Info($"変換前の白色票({white + 1} 番)の画素: {DescribePixelStats(HwTestJudge.SummarizePatchPixels(rawPixels[white]), false)}"); + + var evidenceDir = session.Options.Keep ? PrepareEvidenceDir(session, c) : null; + if (evidenceDir != null) SaveEvidenceImage(c, evidenceDir, "calibrated-frame-raw.png", frame); + + foreach (var ch in channels) { + SetStep(session, $"校正後のフレームの変換({ch} 次元)と色票の差"); + var measured = new double[24, 3]; + var clipped = new HwTestPatchPixelStats[24]; + using (var converted = camera.HwTestConvertToSrgb(frame, ch)) { + for (var i = 0; i < 24; i++) { + var mean = Cv2.Mean(converted, masks[i]); + for (var j = 0; j < 3; j++) measured[i, j] = mean[j]; + clipped[i] = HwTestJudge.SummarizePatchPixels(MaskedPixels(converted, masks[i])); + } + if (evidenceDir != null) SaveEvidenceImage(c, evidenceDir, $"calibrated-frame-srgb{ch:00}.png", converted); + } + + SetStep(session, $"校正後のフレームの 8bit に切り詰める前の値({ch} 次元)"); + var unclipped = new HwTestPatchPixelStats[24]; + var unclippedMeans = new double[24, 3]; + for (var i = 0; i < 24; i++) { + unclipped[i] = HwTestJudge.SummarizePatchPixels(camera.HwTestConvertPixelsUnclipped(rawPixels[i], ch)); + for (var j = 0; j < 3; j++) unclippedMeans[i, j] = unclipped[i].Mean[j]; + } + + var s = PatchColorError.Compare(measured, refArray); + var otherMax = PatchColorError.MaxAbsExcluding(measured, refArray, white, out var otherMaxPatch); + var whiteMax = PatchColorError.PatchMaxAbs(measured, refArray, white); + var ok = HwTestJudge.JudgeColorError(s.MeanAbs, otherMax, whiteMax, + HwTestThresholds.MaxCalibrationMeanError, HwTestThresholds.MaxCalibrationMaxError, HwTestThresholds.MaxCalibrationWhiteMaxError); + var rule = $"上限 暫定 平均 {HwTestThresholds.MaxCalibrationMeanError} / 白色票を除いた最大 {HwTestThresholds.MaxCalibrationMaxError}" + + $" / 白色票 {HwTestThresholds.MaxCalibrationWhiteMaxError}(白飛びの調査までの暫定)"; + var split = $"白色票({white + 1} 番)を除いた最大 {otherMax:0.00}({otherMaxPatch + 1} 番)・白色票の最大 {whiteMax:0.00}"; + c.Expect(ok, + $"{ch} 次元: 校正後のフレームを変換した色票が参照値から離れています: {DescribeColorError(s)},{split}({rule})", + $"{ch} 次元: 校正後のフレームを変換した色票と参照値の差: {DescribeColorError(s)},{split}({rule})"); + c.Info($"{ch} 次元: 色票ごとの差の最大(1〜24 番.8bit 階調): {DescribePerPatch(measured, refArray)}"); + + // 白飛び(255 への頭打ち)の調査用の参考値(判定には使わない) + c.Info($"{ch} 次元: 白色票({white + 1} 番)の変換後の 8bit の画素: {DescribePixelStats(clipped[white], false)}"); + c.Info($"{ch} 次元: 白色票({white + 1} 番)の 8bit に切り詰める前の画素: {DescribePixelStats(unclipped[white], true)}" + + $"(参照値 B{refArray[white, 0]:0.0} G{refArray[white, 1]:0.0} R{refArray[white, 2]:0.0})"); + c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 255 の画素の割合(%.1〜24 番.変換後の 8bit): {DescribePerPatchFraction(clipped, x => x.FractionAnyAtOrAboveMax)}"); + c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 0 の画素の割合(%.1〜24 番.変換後の 8bit): {DescribePerPatchFraction(clipped, x => x.FractionAnyAtOrBelowMin)}"); + c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 255 を超えた画素の割合(%.1〜24 番.8bit に切り詰める前): {DescribePerPatchFraction(unclipped, x => x.FractionAnyAboveMax)}"); + c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 0 未満の画素の割合(%.1〜24 番.8bit に切り詰める前): {DescribePerPatchFraction(unclipped, x => x.FractionAnyBelowMin)}"); + c.Info($"{ch} 次元: 色票ごとの 8bit に切り詰める前の最大(1〜24 番.B/G/R): {DescribePerPatchMax(unclipped)}"); + c.Info($"{ch} 次元: 色票ごとの変換後の 8bit の平均・最小・最大(1〜24 番.B/G/R): {DescribePerPatchStats(clipped)}"); + c.Info($"{ch} 次元: 参考 8bit に切り詰める前の値で色票の平均を取った場合の参照値との差: {DescribeColorError(PatchColorError.Compare(unclippedMeans, refArray))}"); + } + } finally { + frame?.Dispose(); + if (masks != null) { + foreach (var m in masks) m?.Dispose(); + } + } + } + + private static string DescribeColorError(PatchColorErrorStats s) { + return $"チャネルごとの差の絶対値(8bit 階調.{s.Patches} 色票 × BGR)の平均 {s.MeanAbs:0.00}・最大 {s.MaxAbs:0.00}({s.MaxAbsPatch + 1} 番)," + + $"参考 ΔE(CIE76.sRGB D65→Lab)の平均 {s.MeanDeltaE:0.00}・最大 {s.MaxDeltaE:0.00}({s.MaxDeltaEPatch + 1} 番)"; + } + + private static string DescribePerPatch(double[,] measured, double[,] reference) { + var parts = new List(); + for (var i = 0; i < measured.GetLength(0); i++) { + parts.Add($"{PatchColorError.PatchMaxAbs(measured, reference, i):0.0}"); + } + return string.Join(" ", parts); + } + + /// + /// マスク(CV_8UC1.0 以外が色票)の中の画素の値を BGR の並びで取り出す(行 → 列の順.同じマスクなら画像が違っても同じ並び). + /// 画像は CV_8UC3(8bit)を受け付ける. + /// + private static double[] MaskedPixels(Mat image, Mat mask) { + if (image.Type() != OpenCvSharp.MatType.CV_8UC3) throw new ArgumentException($"画素の取り出しは CV_8UC3 の画像だけに対応します({image.Type()})", nameof(image)); + if (image.Size() != mask.Size()) throw new ArgumentException($"画像とマスクの大きさが違います(画像 {image.Size()},マスク {mask.Size()})", nameof(mask)); + var rect = Cv2.BoundingRect(mask); + var values = new List(); + for (var y = rect.Top; y < rect.Bottom; y++) { + for (var x = rect.Left; x < rect.Right; x++) { + if (mask.At(y, x) == 0) continue; + var p = image.At(y, x); + values.Add(p.Item0); + values.Add(p.Item1); + values.Add(p.Item2); + } + } + return values.ToArray(); + } + + /// + /// 証拠の画像(--hwtest-keep のときだけ)の保存先を作業フォルダの下に作る.一時フォルダ(作業フォルダ)の外になる場合は作らない. + /// + /// 保存先(作れなければ null.理由は詳細行に出す) + private static string PrepareEvidenceDir(Session session, SelfTestCheck c) { + var dir = Path.Combine(session.WorkDir, "evidence"); + if (!SelfTestRunner.IsUnder(dir, session.WorkDir) || !SelfTestRunner.IsUnder(dir, Path.GetTempPath())) { + c.Fail($"証拠の画像の保存先が一時フォルダ(作業フォルダ)の外です.保存しません: {dir}"); + return null; + } + try { + Directory.CreateDirectory(dir); + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { + c.Warn($"証拠の画像の保存先を作れません.保存しません: {dir}: {ex.Message}"); + return null; + } + c.Info($"証拠の画像(--hwtest-keep)の保存先: {dir}"); + return dir; + } + + private static void SaveEvidenceImage(SelfTestCheck c, string dir, string name, Mat image) { + var path = Path.Combine(dir, name); + if (IoUtil.SaveImage(path, image)) { + c.Info($"証拠の画像を保存しました: {name}"); + } else { + c.Warn($"証拠の画像を保存できません: {path}"); + } + } + + /// 色票内の画素の集計を 1 行にする(withUnclipped = true なら 255 超・0 未満の割合を出す.false なら 255・0 の割合) + private static string DescribePixelStats(HwTestPatchPixelStats s, bool withUnclipped) { + var text = $"{s.Pixels} 画素,平均 B{s.Mean[0]:0.0} G{s.Mean[1]:0.0} R{s.Mean[2]:0.0}," + + $"最小 B{s.Min[0]:0.0} G{s.Min[1]:0.0} R{s.Min[2]:0.0},最大 B{s.Max[0]:0.0} G{s.Max[1]:0.0} R{s.Max[2]:0.0},"; + return withUnclipped + ? text + $"いずれかのチャネルが 255 を超えた画素 {s.FractionAnyAboveMax:P2}・0 未満の画素 {s.FractionAnyBelowMin:P2}" + : text + $"いずれかのチャネルが 255 の画素 {s.FractionAnyAtOrAboveMax:P2}・0 の画素 {s.FractionAnyAtOrBelowMin:P2}"; + } + + private static string DescribePerPatchFraction(IEnumerable stats, Func fraction) { + return string.Join(" ", stats.Select(s => $"{fraction(s) * 100:0.0}")); + } + + private static string DescribePerPatchMax(IEnumerable stats) { + return string.Join(" ", stats.Select((s, i) => $"{i + 1}:{s.Max[0]:0}/{s.Max[1]:0}/{s.Max[2]:0}")); + } + + private static string DescribePerPatchStats(IEnumerable stats) { + return string.Join("; ", stats.Select((s, i) => + $"{i + 1}: 平均 {s.Mean[0]:0.0}/{s.Mean[1]:0.0}/{s.Mean[2]:0.0} 最小 {s.Min[0]:0}/{s.Min[1]:0}/{s.Min[2]:0} 最大 {s.Max[0]:0}/{s.Max[1]:0}/{s.Max[2]:0}")); + } + + private static string FormatMs(double ms) { + return double.IsNaN(ms) ? "(記録なし)" : $"{ms:0} ms"; + } + + /// 表示の履歴に直前と違う表示だけを足す(件数の上限つき.校正の途中の表示を結果ファイルに残すため) + private static void RecordDistinctMessage(List messages, string message) { + if (string.IsNullOrEmpty(message)) return; + if (messages.Count > 0 && messages[messages.Count - 1] == message) return; + if (messages.Count >= 30) return; + messages.Add(message); + } + + // ================================================================================ + // shot + // ================================================================================ + + /// + /// 単発撮影 1 回と連続撮影(HwTestThresholds.ShotMultiCount 枚・ShotMultiIntervalMs 間隔)を一時フォルダに行い, + /// 保存の完了(保存スレッドの終了)を待って,保存物・撮影ログ・撮影ボタンの再有効化を確かめる. + /// + private static void CheckShot(Session session, SelfTestCheck c) { + var form = session.Form; + var camera = session.Camera; + if (!c.Expect(session.CameraConnected && camera.HwTestIsCalibrated && form.HwTestShotsEnabled, + "校正が済んでいない(撮影ボタンが無効)ため撮影できません(前の calibrate を確認してください)")) return; + + var one = ShotAndVerify(session, c, "単発", "hwtest-shot-one", 1, 0, form.HwTestClickShotOne); + + var n = HwTestThresholds.ShotMultiCount; + var interval = HwTestThresholds.ShotMultiIntervalMs; + form.HwTestSetMultiShots(n, interval); + var multi = ShotAndVerify(session, c, "連続", "hwtest-shot-multi", n, interval, form.HwTestClickShotMulti); + + if (one != null && multi != null) { + c.Expect(!string.Equals(one, multi, StringComparison.OrdinalIgnoreCase), + $"単発と連続の撮影データが同じフォルダに保存されました: {one}"); + } + } + + /// + /// 撮影ボタンを押して(UI スレッドで同期に撮影.保存は保存スレッドで続く),保存の完了を待ち,保存物を確かめる. + /// + /// 撮影データのフォルダ(撮影・保存ができなければ null) + private static string ShotAndVerify(Session session, SelfTestCheck c, string label, string dataName, int count, int intervalMs, Action click) { + var form = session.Form; + var camera = session.Camera; + form.HwTestSetDataName(dataName); + var errorsBefore = form.HwTestErrors.Count; + + SetStep(session, $"{label}撮影({count} 枚・間隔 {intervalMs} ms)"); + var sw = Stopwatch.StartNew(); + click(); + var shotMs = sw.ElapsedMilliseconds; + var savingAfterShot = camera.IsSaving; + var enabledAfterShot = form.HwTestShotsEnabled; + c.Info($"{label}撮影: 撮影 {shotMs} ms(ボタンを押してから戻るまで),直後の状態: 保存中 {(savingAfterShot ? "はい" : "いいえ")},撮影ボタン {(enabledAfterShot ? "有効" : "無効")}"); + // 撮影ボタンは保存の完了まで有効にならない(保存スレッドが終わりに UI スレッドへ投稿する).保存中なのに有効なら不変条件の破れ + c.Expect(!(enabledAfterShot && savingAfterShot), $"{label}撮影: 保存中なのに撮影ボタンが有効です(保存の完了前に再有効化された)"); + + if (!WaitForSave(session, c, label)) return null; + + var folder = camera.HwTestSaveFolder; + VerifyShotFolder(session, c, label, folder, count, intervalMs); + // エラー通知(ShowError)は項目の終わりに RunCheck がまとめて FAIL にする.ここでは件数だけを出す + var newErrors = form.HwTestErrors.Count - errorsBefore; + c.Info(newErrors == 0 ? $"{label}撮影: エラー通知なし" : $"{label}撮影: エラー通知 {newErrors} 件(内容は項目の最後の FAIL)"); + c.Info($"{label}撮影: 保存完了後の表示「{form.HwTestMessage}」"); + return folder; + } + + /// 保存スレッドの終了を待ち,保存スレッドからの投稿を処理させてから撮影ボタンの再有効化を確かめる + /// 上限内に保存が終わったら true + private static bool WaitForSave(Session session, SelfTestCheck c, string label) { + var form = session.Form; + var camera = session.Camera; + SetStep(session, $"{label}: 保存の完了(保存スレッドの終了)の待機"); + var sw = Stopwatch.StartNew(); + var saved = SelfTestPump.PumpUntil(() => !camera.IsSaving, HwTestThresholds.SaveWait); + var saveMs = sw.ElapsedMilliseconds; + if (!c.Expect(saved, $"{label}: {HwTestThresholds.SaveWait.TotalSeconds:0} 秒以内に保存が終わりません(表示「{form.HwTestMessage}」)")) return false; + SelfTestPump.PumpFor(HwTestThresholds.AfterSaveSettle); + var enabled = SelfTestPump.PumpUntil(() => form.HwTestShotsEnabled, HwTestThresholds.ShotsEnabledWait); + c.Expect(enabled, $"{label}: 保存の完了後に撮影ボタンが再有効化されません(SaveThread → EnableShots(true))", + $"{label}: 撮影の後 {saveMs} ms で保存が終わり,撮影ボタンが再有効化されました"); + return true; + } + + /// + /// 撮影データのフォルダの中身(撮影画像・sRGB 変換画像・Info.csv の過不足と大きさ)と,撮影ログの行数・撮影時刻の間隔を確かめる. + /// + private static void VerifyShotFolder(Session session, SelfTestCheck c, string label, string folder, int count, int intervalMs) { + var camera = session.Camera; + if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"{label}: 撮影データのフォルダがありません: {folder}")) return; + c.Expect(SelfTestRunner.IsUnder(folder, session.SaveRoot), $"{label}: 撮影データが一時フォルダの外に保存されました: {folder}"); + c.Expect(!string.Equals(folder, camera.HwTestCalibrationFolder, StringComparison.OrdinalIgnoreCase), + $"{label}: 撮影データが校正データと同じフォルダに保存されました: {folder}"); + + var infoName = Config.GetString("File/Info"); + var expected = HwTestJudge.ExpectedShotFiles(Config.GetString("File/RgbImage"), Config.GetString("File/SrgbImage"), infoName, + camera.HwTestConversionChannels, count); + var files = Directory.GetFiles(folder); + HwTestJudge.CompareFileNames(expected, files.Select(Path.GetFileName), out var missing, out var unexpected); + var empty = files.Where(f => new FileInfo(f).Length == 0).Select(Path.GetFileName).ToList(); + c.Info($"{label}: 撮影データ {folder}({files.Length} ファイル.期待 {expected.Count}: 撮影画像 {count}・sRGB 変換画像 {count} × {camera.HwTestConversionChannels.Count}・{infoName})"); + c.Expect(missing.Count == 0, $"{label}: 保存されていないファイルがあります: {string.Join(", ", missing)}"); + if (unexpected.Count > 0) c.Warn($"{label}: 想定していないファイルがあります: {string.Join(", ", unexpected)}"); + c.Expect(empty.Count == 0, $"{label}: 大きさ 0 のファイルがあります: {string.Join(", ", empty)}"); + + var infoPath = Path.Combine(folder, infoName); + if (!File.Exists(infoPath)) return; + var rows = HwTestJudge.ParseShotLog(File.ReadAllLines(infoPath), out var parseError); + if (!c.Expect(parseError == null, $"{label}: 撮影ログ({infoName})を読めません: {parseError}")) return; + c.Info($"{label}: 撮影ログの撮影時刻(ms): {string.Join(", ", rows.Select(r => r.TimeMs))}"); + c.Expect(rows.Count == count, $"{label}: 撮影ログの行数が撮影枚数と違います: {rows.Count} 行(期待 {count})"); + var notes = rows.Where(r => !string.IsNullOrEmpty(r.Note)).Select(r => $"{r.Shot}: {r.Note}").ToList(); + c.Expect(notes.Count == 0, $"{label}: 撮影ログに中止等の備考があります: {string.Join(" / ", notes)}"); + if (count >= 2) { + var dev = HwTestJudge.MaxIntervalDeviationMs(rows.Select(r => r.TimeMs).ToList(), intervalMs); + c.Expect(!double.IsNaN(dev) && dev <= HwTestThresholds.ShotIntervalToleranceMs, + $"{label}: 撮影時刻の間隔が設定値({intervalMs} ms)から離れています: 差の最大 {dev:0} ms(許容 暫定 {HwTestThresholds.ShotIntervalToleranceMs} ms)", + $"{label}: 撮影時刻の間隔と設定値({intervalMs} ms)の差の最大 {dev:0} ms(許容 暫定 {HwTestThresholds.ShotIntervalToleranceMs} ms)"); + } + } + + // ================================================================================ + // close-saving + // ================================================================================ + + /// + /// 保存に時間がかかる連続撮影(間隔 0)の直後,保存中(CameraBase.IsSaving)に閉じる処理を呼び,閉じるのが取り消されること + /// (表示が Form1.SavingOnCloseMessage・後始末が走らない)を確かめる.保存の完了後にもう一度閉じる処理を呼び,閉じられること + /// (段階 closing → timer → camera → light)を確かめる.保存中の状態を作れなかった場合は WARN にして枚数を増やして 1 回だけ再試行する. + /// + private static void CheckCloseSaving(Session session, SelfTestCheck c) { + var form = session.Form; + var camera = session.Camera; + if (!c.Expect(session.CameraConnected && camera.HwTestIsCalibrated && form.HwTestShotsEnabled, + "校正が済んでいない(撮影ボタンが無効)ため保存中の状態を作れません(前の calibrate・shot を確認してください)")) return; + var lightWasOpen = form.HwTestLightIsOpen; + + var decision = HwTestSavingAttempt.GiveUp; + string folder = null; + var count = 0; + for (var attempt = 1; ; attempt++) { + count = HwTestJudge.CloseSavingShotCount(attempt); + form.HwTestSetDataName($"hwtest-close-saving-{attempt}"); + form.HwTestSetMultiShots(count, 0); + SetStep(session, $"連続撮影({count} 枚・間隔 0 ms.試行 {attempt})"); + var sw = Stopwatch.StartNew(); + form.HwTestClickShotMulti(); + var saving = camera.IsSaving; + folder = camera.HwTestSaveFolder; + c.Info($"試行 {attempt}: 連続撮影 {count} 枚(間隔 0 ms)の撮影 {sw.ElapsedMilliseconds} ms,直後の保存中: {(saving ? "はい" : "いいえ")}"); + decision = HwTestJudge.DecideSavingAttempt(saving, attempt, HwTestThresholds.CloseSavingMaxAttempts); + if (decision == HwTestSavingAttempt.Ready) break; + c.Warn(decision == HwTestSavingAttempt.Retry + ? $"保存中の状態を作れなかった(撮影の直後に保存が終わっていた.試行 {attempt}).枚数を増やして再試行します" + : $"保存中の状態を作れなかった({attempt} 回試行).保存中に閉じる取り消しの検査を省き,保存後に閉じられることだけを確かめます"); + if (!WaitForSave(session, c, $"試行 {attempt}")) return; + if (decision == HwTestSavingAttempt.GiveUp) break; + } + + if (decision == HwTestSavingAttempt.Ready && !camera.IsSaving) { + // 連続撮影の直後の確認から閉じる処理を呼ぶまでの間に保存が終わった(ほぼ起きない) + c.Warn("保存中の状態を作れなかった(閉じる処理を呼ぶ直前に保存が終わった).保存中に閉じる取り消しの検査を省きます"); + decision = HwTestSavingAttempt.GiveUp; + if (!WaitForSave(session, c, "保存の完了")) return; + } + if (decision == HwTestSavingAttempt.Ready) { + if (!CheckCancelledClose(session, c, lightWasOpen)) return; + if (!WaitForSave(session, c, "保存中に閉じた後")) return; + VerifyShotFolder(session, c, "保存中に閉じた後", folder, count, 0); + } + + CheckFinalClose(session, c, "保存の完了後に閉じる"); + } + + /// 保存中に閉じる処理を呼び,取り消されたことを確かめる + /// 取り消された(後続の検査を続けられる)なら true.閉じてしまったら false + private static bool CheckCancelledClose(Session session, SelfTestCheck c, bool lightWasOpen) { + var form = session.Form; + var camera = session.Camera; + var stepsBefore = form.HwTestCloseSteps.Count; + SetStep(session, "保存中に閉じる処理(Form1_FormClosing.取り消されるはず)"); + var closed = RequestCloseObserved(session, stepsBefore, out var elapsed, out var error, out _, out var added); + // 表示は閉じる処理が同期に設定する.保存スレッドの投稿(保存の進み具合)で上書きされる前に読む(メッセージを処理する前) + var shown = form.HwTestMessage; + var savingAfter = camera.IsSaving; + + c.Info($"保存中に閉じる処理: {elapsed.TotalMilliseconds:0} ms,閉じると決まった: {(closed ? "はい" : "いいえ")},表示「{shown}」," + + $"進んだ後始末の段階: {(added.Count == 0 ? "なし" : string.Join(" → ", added))},直後も保存中: {(savingAfter ? "はい" : "いいえ")}"); + if (error != null) c.FailException("保存中に閉じる処理で例外", error); + var problems = HwTestJudge.CancelledCloseProblems(closed, shown, Form1.SavingOnCloseMessage, added.Count, + form.HwTestIsClosing, form.HwTestRefreshTimerRunning); + foreach (var p in problems) c.Fail($"保存中に閉じる処理: {p}"); + if (problems.Count == 0) c.Info($"保存中に閉じる処理は取り消されました(表示「{Form1.SavingOnCloseMessage}」,後始末は走っていません)"); + c.Expect(form.HwTestLightIsOpen == lightWasOpen, "保存中に閉じる処理の後に光源のポートの状態が変わりました(後始末が走った疑い)"); + return !closed; + } + + /// + /// 閉じる処理を呼び,閉じられること(所要時間・例外の有無・後始末の順序・閉じた後のフレーム)を確かめる(close-saving・close-calibrating 共通). + /// + private static void CheckFinalClose(Session session, SelfTestCheck c, string label) { + var form = session.Form; + if (!c.Expect(session.CameraConnected, $"{label}: カメラが接続されていません")) return; + var stepsBefore = form.HwTestCloseSteps.Count; + SetStep(session, $"{label}(Form1_FormClosing → ShutdownOnClose)"); + var closed = RequestCloseObserved(session, stepsBefore, out var elapsed, out var error, out var steps, out var added); + + c.Info($"{label}: 所要時間 {elapsed.TotalMilliseconds:0} ms,通過した段階: {string.Join(" → ", added)}"); + if (error != null) c.FailException($"{label}: 閉じる処理で例外", error); + c.Expect(closed, $"{label}: 閉じる処理が取り消されました(保存中と判定された.保存中: {(session.Camera.IsSaving ? "はい" : "いいえ")})"); + c.Expect(HwTestJudge.WithinLimit(elapsed, HwTestThresholds.CloseLimit), + $"{label}: 閉じる処理が上限(暫定 {HwTestThresholds.CloseLimit.TotalMilliseconds:0} ms)を超えました: {elapsed.TotalMilliseconds:0} ms"); + c.Expect(HwTestJudge.IsCloseSequenceSince(steps, stepsBefore), + $"{label}: 後始末の順序が不正です: {string.Join(" → ", added)}(期待 closing → timer → camera → light)"); + c.Expect(form.HwTestIsClosing, $"{label}: 閉じ始めのフラグが立っていません"); + c.Expect(!form.HwTestRefreshTimerRunning, $"{label}: 本モニタの再描画タイマーが止まっていません"); + c.Expect(!form.HwTestLightIsOpen, $"{label}: 閉じた後も光源のポートが開いています"); + + SetStep(session, $"{label}: 閉じた後にフレームが止まったかの確認"); + var afterStart = form.HwTestFrameCount; + SelfTestPump.PumpFor(HwTestThresholds.AfterCloseObserve); + var afterFrames = form.HwTestFrameCount - afterStart; + c.Expect(afterFrames <= HwTestThresholds.MaxFramesAfterClose, + $"{label}: 閉じた後もフレームが届いています: {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒で {afterFrames} フレーム(上限 暫定 {HwTestThresholds.MaxFramesAfterClose})", + $"{label}: 閉じた後 {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒間に届いたフレーム: {afterFrames}"); + } + + /// + /// 閉じる処理(HwTestRequestClose)を呼び,所要時間・例外・その回に進んだ後始末の段階を返す(CheckCancelledClose・CheckFinalClose 共通). + /// 後始末がカメラの切断まで進んだら session.CameraConnected を下ろす.メッセージは処理しない(直後の表示を呼び出し側が読めるように). + /// + /// 閉じる処理の前の後始末の段階の記録の件数 + /// 閉じる処理の後の後始末の段階の記録(全件) + /// steps のうち stepsBefore 以降(その回の閉じる処理の分) + /// 閉じる処理が閉じると決めたか(例外のときは false) + private static bool RequestCloseObserved(Session session, int stepsBefore, out TimeSpan elapsed, out Exception error, + out IReadOnlyList steps, out List added) { + var form = session.Form; + var sw = Stopwatch.StartNew(); + var closed = false; + error = null; + try { + closed = form.HwTestRequestClose(); + } catch (Exception ex) { + error = ex; + } + elapsed = sw.Elapsed; + steps = form.HwTestCloseSteps; + added = HwTestJudge.StepsSince(steps, stepsBefore); + if (added.Contains("camera")) session.CameraConnected = false; + return closed; + } + + // ================================================================================ + // close-calibrating + // ================================================================================ + + /// + /// 画面の状態を戻して再接続し(校正前の状態に戻る),校正中(CameraBase.IsCalibrating)になるのを待って閉じる処理を呼び, + /// すぐ戻ること(所要時間・例外の有無・後始末の順序・閉じた後のフレーム)を確かめる. + /// + private static void CheckCloseCalibrating(Session session, SelfTestCheck c) { + var form = session.Form; + if (session.CameraConnected) { + // 前の項目で閉じられなかった場合.閉じる処理を通さずに切断してから再接続する + SetStep(session, "切断(Disconnect)"); + form.HwTestDisconnectCamera(); + session.CameraConnected = false; + c.Info("前の項目で閉じられなかったカメラを切断しました"); + } + + SetStep(session, "画面の状態を接続直後に戻す(閉じ始めのフラグ・再描画タイマー・光源)"); + var lightOpen = form.HwTestResumeAfterClose(); + c.Info($"画面の状態を戻しました(光源のポート: {(lightOpen ? "開いて点灯" : "開けなかった")})"); + + SetStep(session, "Connect(再接続.校正前の状態に戻る)"); + var sw = Stopwatch.StartNew(); + var camera = form.HwTestConnect(session.Options.Camera); + var connectErrors = form.HwTestTakeConnectErrors(); + if (!c.Expect(camera != null, $"再接続の Connect() に失敗しました({string.Join(" / ", connectErrors)})")) return; + session.Camera = camera; + session.CameraConnected = true; + c.Info($"再接続の Connect(): {sw.ElapsedMilliseconds} ms(機種名 {camera.DeviceName},シリアル番号 {camera.SerialNumber}.ホワイトバランスはデバイス状態ファイルの値に戻る)"); + c.Expect(camera.SerialNumber == session.FirstSerial, + $"再接続したカメラのシリアル番号が 1 回目と異なります: {camera.SerialNumber}(1 回目 {session.FirstSerial})"); + + SetStep(session, "校正中(チャート検出 → ホワイトバランスの調整中)になるまでの待機"); + var calibrating = SelfTestPump.PumpUntil(() => camera.IsCalibrating || camera.HwTestIsCalibrated, HwTestThresholds.CalibratingWait); + var detectMs = HwTestJudge.TimestampDeltaMs(camera.HwTestFirstFrameAt, camera.HwTestChartDetectedAt, Stopwatch.Frequency); + if (!c.Expect(calibrating && camera.IsCalibrating, + calibrating + ? "校正中になる前に校正が完了しました(校正中に閉じる検査になりません)" + : $"{HwTestThresholds.CalibratingWait.TotalSeconds:0} 秒以内に校正中になりません({form.HwTestFrameCount} フレーム,最後の表示「{form.HwTestMessage}」.チャートを確認してください)")) return; + c.Info($"校正中になりました(最初のフレーム → チャート検出 {FormatMs(detectMs)})"); + + SetStep(session, "校正の途中まで進める"); + SelfTestPump.PumpFor(HwTestThresholds.CalibratingBeforeClose); + var stillCalibrating = camera.IsCalibrating; + if (stillCalibrating) { + c.Info($"閉じる処理の直前も校正中です(校正中になってから {HwTestThresholds.CalibratingBeforeClose.TotalMilliseconds:0} ms 後)"); + } else { + c.Warn("閉じる処理を呼ぶ前に校正が終わりました(校正中に閉じる検査になっていません.Calib/Frames を確認してください)"); + } + + CheckFinalClose(session, c, "校正中に閉じる"); + c.Info($"閉じた後の校正の状態: {(camera.IsCalibrating ? "校正中のまま止まった" : camera.HwTestIsCalibrated ? "校正済み" : "未校正")}(切断で校正の途中の状態は捨てられ,次の接続でやり直す)"); + } + } +} +#endif diff --git a/TIASshot/HwTest/HwTestRunner.Hang.cs b/TIASshot/HwTest/HwTestRunner.Hang.cs new file mode 100644 index 0000000..f72aa7d --- /dev/null +++ b/TIASshot/HwTest/HwTestRunner.Hang.cs @@ -0,0 +1,187 @@ +#if HWTEST +#if !SELFTEST +#error HWTEST は SELFTEST の部品(SelfTestReport)を使うため,SELFTEST と同時に定義すること +#endif +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +namespace TIASshot { + + /// + /// 実機テストのタイムアウト時に止まった場所を記録する(HWTEST 限定.本番・SELFTEST のみのビルドには含まれない). + /// ウォッチドッグが項目・全体の上限を超えたと判定したときだけ呼ばれ, + /// UI スレッド・保存スレッドのマネージドスタックを結果ファイルの詳細行に,自プロセスのミニダンプを作業フォルダに書く. + /// どちらも上限時間を設けた別スレッドで行い,取得に失敗・時間切れしてもウォッチドッグの流れ(光源のポートを閉じる → 終了コード 3)は止めない. + /// + internal static partial class HwTestRunner { + + /// + /// UI スレッドと保存スレッド(生きていれば)のマネージドスタックを取り,結果ファイルの詳細行(" - " の後ろ)にする行の並びを返す. + /// 失敗・時間切れの場合も理由を行にして返す(例外は漏らさない). + /// + private static List CaptureHangStacks(Session session) { + var lines = new List(); + AppendThreadStack(lines, "UI スレッド", session.UiThread); + Thread saveThread = null; + try { + saveThread = session.Camera?.HwTestSaveThread; + } catch (Exception ex) { + lines.Add($"保存スレッド: 参照を取れません: {ex.GetType().Name}: {ex.Message}"); + Debug.WriteLine($"[HWTEST] 保存スレッドの参照を取れません: {ex}"); + } + if (saveThread == null) { + lines.Add("保存スレッド: なし(まだ起動していない)"); + } else if (!saveThread.IsAlive) { + lines.Add("保存スレッド: 終了済み"); + } else { + AppendThreadStack(lines, "保存スレッド", saveThread); + } + return lines; + } + + /// + /// 1 スレッドのマネージドスタックを取る.取得は別スレッドで行い,HwTestThresholds.HangStackTimeout で打ち切る + /// (Suspend が安全点に届かない等でウォッチドッグ自体が止まらないため). + /// + private static void AppendThreadStack(List lines, string label, Thread target) { + if (target == null) { + lines.Add($"{label}: スレッドが記録されていません"); + return; + } + var name = $"{label}(ManagedThreadId {target.ManagedThreadId}{(string.IsNullOrEmpty(target.Name) ? "" : $",{target.Name}")})"; + if (!target.IsAlive) { + lines.Add($"{name}: 終了済みのためスタックを取れません"); + return; + } + string stack = null; + string error = null; + var helper = new Thread(() => { + try { + stack = CaptureStack(target); + } catch (Exception ex) { + error = $"{ex.GetType().Name}: {ex.Message}"; + Debug.WriteLine($"[HWTEST] {label}のスタックを取れません: {ex}"); + } + }) { IsBackground = true, Name = "HwTest.StackCapture" }; + helper.Start(); + if (!helper.Join(HwTestThresholds.HangStackTimeout)) { + lines.Add($"{name}: スタックの取得が {HwTestThresholds.HangStackTimeout.TotalSeconds:0} 秒以内に終わりません(Suspend が戻らない可能性.ダンプで確認してください)"); + return; + } + if (error != null) { + lines.Add($"{name}: スタックを取れません: {error}"); + return; + } + var frames = stack.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + lines.Add($"{name}のマネージドスタック({frames.Length} 行.上が最も内側.ネイティブのフレームは含まない):"); + foreach (var f in frames) lines.Add($" {f.Trim()}"); + } + + /// + /// 対象スレッドを一時停止してマネージドスタックを取り,必ず再開する. + /// .NET Framework 4.8 で他スレッドのスタックを取る唯一の手段のため,Obsolete の Thread.Suspend / Resume と + /// StackTrace(Thread, bool) を使う(HWTEST 限定.タイムアウト後にプロセスを終了する直前だけ呼ぶ). + /// + private static string CaptureStack(Thread target) { +#pragma warning disable 618 // Thread.Suspend / Resume・StackTrace(Thread, bool) は Obsolete(他スレッドのスタックを取る目的に限って使う) + target.Suspend(); + try { + return new StackTrace(target, true).ToString(); + } finally { + target.Resume(); + } +#pragma warning restore 618 + } + + /// + /// 自プロセスのミニダンプを作業フォルダ(HwTestJudge.HangDumpPath)に書き,パスと大きさ(書けなければ WARN)を結果ファイルに出す. + /// 種類(全メモリ/小さいダンプ/書かない)はプライベートメモリと空き容量から HwTestJudge.ChooseDumpKind で決める. + /// 書き出しは別スレッドで行い,HwTestThresholds.HangDumpTimeout で打ち切る(例外は漏らさない). + /// + private static void WriteHangDump(Session session) { + var report = session.Report; + string path; + HwTestDumpKind kind; + long privateBytes = -1; + long freeBytes = -1; + try { + path = HwTestJudge.HangDumpPath(session.WorkDir, DateTime.Now); + Directory.CreateDirectory(session.WorkDir); + using (var self = Process.GetCurrentProcess()) privateBytes = self.PrivateMemorySize64; + var root = Path.GetPathRoot(Path.GetFullPath(path)); + freeBytes = new DriveInfo(root).AvailableFreeSpace; + kind = HwTestJudge.ChooseDumpKind(privateBytes, freeBytes); + } catch (Exception ex) { + report.WriteLine($" WARN: ミニダンプの準備に失敗したため書きません: {SelfTestReport.DescribeException(ex)}"); + return; + } + if (kind == HwTestDumpKind.None) { + report.WriteLine($" WARN: 空き容量が足りないためミニダンプを書きません(空き {FormatBytes(freeBytes)},必要 {FormatBytes(HwTestThresholds.SmallDumpMinFreeBytes)} 以上)"); + return; + } + + string error = null; + var sw = Stopwatch.StartNew(); + var helper = new Thread(() => { + try { + WriteMiniDump(path, HwTestJudge.DumpTypeFlags(kind)); + } catch (Exception ex) { + error = SelfTestReport.DescribeException(ex); + Debug.WriteLine($"[HWTEST] ミニダンプを書けません: {path}: {ex}"); + } + }) { IsBackground = true, Name = "HwTest.MiniDump" }; + helper.Start(); + var kindText = kind == HwTestDumpKind.Full + ? "全メモリ" + : $"小さいダンプ(全メモリにしなかった理由: プライベートメモリ {FormatBytes(privateBytes)},空き {FormatBytes(freeBytes)})"; + if (!helper.Join(HwTestThresholds.HangDumpTimeout)) { + report.WriteLine($" WARN: ミニダンプの書き出しが {HwTestThresholds.HangDumpTimeout.TotalSeconds:0} 秒以内に終わりません({kindText}.途中までのファイルが残る可能性: {path})"); + return; + } + if (error != null) { + report.WriteLine($" WARN: ミニダンプを書けませんでした({kindText}): {path}: {error}"); + return; + } + long size = -1; + try { + size = new FileInfo(path).Length; + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { + Debug.WriteLine($"[HWTEST] ミニダンプの大きさを取れません: {path}: {ex.Message}"); + } + report.WriteLine($" - ミニダンプ: {path}({kindText},{(size >= 0 ? FormatBytes(size) : "大きさ不明")},{sw.ElapsedMilliseconds} ms." + + $"プライベートメモリ {FormatBytes(privateBytes)}.MINIDUMP_TYPE 0x{HwTestJudge.DumpTypeFlags(kind):X})"); + } + + /// dbghelp の MiniDumpWriteDump で自プロセスのダンプを書く(失敗は Win32Exception.途中まで書いたファイルは残す) + private static void WriteMiniDump(string path, uint dumpType) { + using (var self = Process.GetCurrentProcess()) + using (var stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) { + if (!NativeMethods.MiniDumpWriteDump(self.Handle, (uint)self.Id, stream.SafeFileHandle, dumpType, + IntPtr.Zero, IntPtr.Zero, IntPtr.Zero)) { + // MiniDumpWriteDump は失敗の理由を HRESULT として GetLastError で返す + var err = Marshal.GetLastWin32Error(); + throw new Win32Exception(err, $"MiniDumpWriteDump が失敗しました(0x{err:X8})"); + } + } + } + + private static string FormatBytes(long bytes) { + if (bytes < 0) return "不明"; + return $"{bytes / (1024.0 * 1024.0):0.0} MB"; + } + + private static class NativeMethods { + [DllImport("dbghelp.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool MiniDumpWriteDump(IntPtr hProcess, uint processId, SafeFileHandle hFile, uint dumpType, + IntPtr exceptionParam, IntPtr userStreamParam, IntPtr callbackParam); + } + } +} +#endif diff --git a/TIASshot/HwTest/HwTestRunner.cs b/TIASshot/HwTest/HwTestRunner.cs index 55a33c8..45633ab 100644 --- a/TIASshot/HwTest/HwTestRunner.cs +++ b/TIASshot/HwTest/HwTestRunner.cs @@ -26,19 +26,22 @@ /// reconnect: 切断した後に再接続でき,フレームが届く /// cleanup: 後始末(露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯はしない))と,設定・デバイス状態ファイルを書き換えていないこと /// + /// --with-chart を指定すると,上の preview〜reconnect の代わりにチャートが必要な次の項目を行う(校正は止めない.connect・cleanup は共通). + /// + /// calibrate: 実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値(tcc_srgb)に近い + /// shot: 単発・連続撮影で撮影画像・sRGB 変換画像・Info.csv が保存され,撮影時刻の間隔が設定どおり + /// close-saving: 保存中に閉じようとすると取り消され,保存後は閉じられる(後始末の順序) + /// close-calibrating: 再接続して校正中に閉じてもすぐ戻る + /// /// 保存先は一時フォルダ(%TEMP%\TIASshot-hwtest\work-*)に差し替え,同梱 config.xml・デバイス状態ファイル({DeviceName}.xml)・本番の保存先には書き込まない. - /// 終了コード: 0=全項目合格,1=不合格あり,2=実機テスト自体の異常(引数不正・指定機種が見つからない・未実装の指定等),3=タイムアウト. + /// 終了コード: 0=全項目合格,1=不合格あり,2=実機テスト自体の異常(引数不正・指定機種が見つからない等),3=タイムアウト. /// 【注意】判定のしきい値(HwTestThresholds)は暫定値.結果ファイルには必ず測定値を出す. /// - internal static class HwTestRunner { + internal static partial class HwTestRunner { /// 作業フォルダに書き出す一時設定ファイルの名前(セルフテストの config.selftest.xml と区別する) internal const string ConfigFileName = "config.hwtest.xml"; - private static readonly string[] CheckIds = { - "connect", "preview", "exposure-gain", "light", "close-preview", "reconnect", "cleanup", - }; - public static bool IsRequested(string[] args) { return HwTestOptions.IsRequested(args); } @@ -82,19 +85,15 @@ return HwTestExitCodes.Error; } report.WriteLine($" camera: {options.Camera}"); - if (options.WithChart) { - // フェーズ 2(calibrate・shot・close-saving・close-calibrating)は未実装.機材には触れずに終える - report.WriteLine(); - report.WriteLine($"[FAIL] with-chart: {HwTestOptions.FlagWithChart}(チャートが必要な検査.PLAN_06 フェーズ 2)は未実装です(カメラ・光源には触れていません)"); - report.WriteLine("RESULT: FAIL (not implemented: --with-chart)"); - return HwTestExitCodes.Error; - } + report.WriteLine($" checks: {string.Join(", ", HwTestJudge.CheckIds(options.WithChart))}" + + (options.WithChart ? "(チャートが必要な検査.チャート不要の検査は --with-chart なしで別に実行する)" : "")); report.WriteLine($" work dir: {workDir}"); - report.WriteLine($" thresholds (暫定): {DescribeThresholds()}"); + report.WriteLine($" thresholds (暫定): {DescribeThresholds(options.WithChart)}"); + report.WriteLine($" overall timeout: {HwTestJudge.OverallTimeout(options.WithChart).TotalSeconds:0} 秒"); var suppressCalibration = HwTestJudge.ShouldSuppressCalibration(options.WithChart); report.WriteLine(suppressCalibration ? " 校正: 停止(チャート不要の検査.チャートが写っても校正(チャート検出・ホワイトバランスの調整・変換行列の算出)は走らない)" - : " 校正: 有効"); + : " 校正: 有効(チャートが必要な検査.カメラの前に舌診チャートがはまっている前提.校正でカメラのホワイトバランスが変わるが,次の接続でデバイス状態ファイルの値に戻る)"); report.WriteLine(); var session = new Session { @@ -103,6 +102,7 @@ BaseDir = baseDir, WorkDir = workDir, CalibrationSuppressed = suppressCalibration, + UiThread = Thread.CurrentThread, }; // カメラの接続(コールバック開始)より前に設定する.static のため再接続で作り直したカメラにも効く CameraBase.HwTestCalibrationSuppressed = suppressCalibration; @@ -147,6 +147,7 @@ public string WorkDir; public string SaveRoot; public string UserSaveFolder; + public Thread UiThread; // Form1 が動くスレッド(Run を呼んだスレッド.タイムアウト時にスタックを取る) public Form1 Form; public CameraBase Camera; // 現在のカメラ @@ -190,7 +191,17 @@ return string.Join(" ", flags); } - private static string DescribeThresholds() { + private static string DescribeThresholds(bool withChart) { + if (withChart) { + return $"calibration wait<={HwTestThresholds.CalibrationWait.TotalSeconds:0}s, " + + $"color error (8bit, |measured-ref| per channel) mean<={HwTestThresholds.MaxCalibrationMeanError} " + + $"max (white patch excluded)<={HwTestThresholds.MaxCalibrationMaxError} white patch max<={HwTestThresholds.MaxCalibrationWhiteMaxError} (白飛びの調査までの暫定) (ΔE76・白飛びの割合は参考), " + + $"multi shot {HwTestThresholds.ShotMultiCount}x{HwTestThresholds.ShotMultiIntervalMs}ms interval tolerance<={HwTestThresholds.ShotIntervalToleranceMs}ms, " + + $"save wait<={HwTestThresholds.SaveWait.TotalSeconds:0}s, " + + $"close-saving shots {HwTestThresholds.CloseSavingShotCount} (retry {HwTestThresholds.CloseSavingRetryShotCount}) x0ms, " + + $"calibrating wait<={HwTestThresholds.CalibratingWait.TotalSeconds:0}s, close<={HwTestThresholds.CloseLimit.TotalMilliseconds:0}ms, " + + $"frames after close<={HwTestThresholds.MaxFramesAfterClose}"; + } return $"preview>={HwTestThresholds.MinPreviewFps}fps, " + $"exposure ratio down<={HwTestThresholds.ExposureLowerMaxRatio} up>={HwTestThresholds.ExposureRaiseMinRatio}, " + $"gain delta>={HwTestThresholds.MinBrightnessDelta} (one side ok), " @@ -232,16 +243,20 @@ itemElapsed = session.ItemWatch.Elapsed; limit = session.CurrentLimit; } - var overdue = HwTestJudge.FindOverdue(overall.Elapsed, HwTestThresholds.OverallTimeout, itemElapsed, limit); + var overallLimit = HwTestJudge.OverallTimeout(session.Options.WithChart); + var overdue = HwTestJudge.FindOverdue(overall.Elapsed, overallLimit, itemElapsed, limit); if (overdue == HwTestOverdue.None) continue; var report = session.Report; var reason = overdue == HwTestOverdue.Item ? $"項目 {id} が {limit.Value.TotalSeconds:0} 秒以内に終わりません" - : $"実機テスト全体が {HwTestThresholds.OverallTimeout.TotalSeconds:0} 秒以内に終わりません"; + : $"実機テスト全体が {overallLimit.TotalSeconds:0} 秒以内に終わりません"; var message = $"{reason}(実行中のステップ: {step ?? "不明"}.SDK 呼び出しのハング・Invoke の相互待機・モーダル表示の疑い)"; + // 止まった場所の記録: 結果ファイルを書く前に(状態が動く前に)UI スレッド・保存スレッドのマネージドスタックを取る + var stackLines = CaptureHangStacks(session); if (check != null) { check.Fail($"タイムアウト: {message}"); + foreach (var line in stackLines) check.Info(line); try { report.WriteCheck(check); } catch (InvalidOperationException ex) { @@ -251,9 +266,16 @@ } } else { report.WriteLine($"[FAIL] watchdog: {message}"); + foreach (var line in stackLines) report.WriteLine($" - {line}"); } + // ミニダンプ(自プロセス)を作業フォルダに書く.光源のポートを閉じる前に書く(止まった状態をなるべく崩さないため) + WriteHangDump(session); EmergencyCleanup(session); report.WriteLine($"RESULT: FAIL (timeout{(id != null ? $": {id}" : "")})"); + // タイムアウト時は作業フォルダ(ダンプ・撮影データ)を残す(Environment.Exit で CleanupWorkDir に届かないため,ここで残したことを出す) + if (HwTestJudge.ShouldKeepWorkDir(session.Options.Keep, HwTestExitCodes.Timeout) && Directory.Exists(session.WorkDir)) { + report.WriteLine($"work dir kept: {session.WorkDir}"); + } Environment.Exit(HwTestExitCodes.Timeout); } }) { @@ -366,7 +388,17 @@ HwTestThresholds.ConnectTimeout, c => CheckConnect(session, c)); checks.Add(connect); - if (connect.Status == SelfTestStatus.Pass) { + var ids = HwTestJudge.CheckIds(session.Options.WithChart); + if (connect.Status == SelfTestStatus.Pass && session.Options.WithChart) { + checks.Add(RunCheck(session, "calibrate", "実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値に近い", + HwTestThresholds.CalibrateTimeout, c => CheckCalibrate(session, c))); + checks.Add(RunCheck(session, "shot", "単発・連続撮影で撮影画像・sRGB 変換画像・Info.csv が保存され,撮影時刻の間隔が設定どおり", + HwTestThresholds.ShotTimeout, c => CheckShot(session, c))); + checks.Add(RunCheck(session, "close-saving", "保存中に閉じようとすると取り消され,保存の完了後は閉じられる", + HwTestThresholds.CloseSavingTimeout, c => CheckCloseSaving(session, c))); + checks.Add(RunCheck(session, "close-calibrating", "再接続して校正中に閉じる処理を呼んでもすぐ戻る", + HwTestThresholds.CloseCalibratingTimeout, c => CheckCloseCalibrating(session, c))); + } else if (connect.Status == SelfTestStatus.Pass) { checks.Add(RunCheck(session, "preview", "プレビューが流れる(フレームレートの下限)", HwTestThresholds.PreviewTimeout, c => CheckPreview(session, c))); checks.Add(RunCheck(session, "exposure-gain", "露光・ゲインの上げ下げがフレームの平均輝度に期待どおりの向きで効く", @@ -381,7 +413,7 @@ var reason = session.CameraNotFound ? $"指定した機種({session.Options.Camera})が見つからないため実行しません" : "前提の connect が失敗したため実行しません"; - foreach (var id in CheckIds.Skip(1).Take(5)) checks.Add(SkipCheck(session, id, reason)); + foreach (var id in ids.Skip(1).Take(ids.Length - 2)) checks.Add(SkipCheck(session, id, reason)); } checks.Add(RunCheck(session, "cleanup", "後始末(露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる)と,設定・デバイス状態ファイル・本番の保存先を書き換えていない", @@ -872,7 +904,7 @@ c.Expect(closed, "閉じる処理が取り消されました(保存中と判定された)"); c.Expect(HwTestJudge.WithinLimit(elapsed, HwTestThresholds.CloseLimit), $"閉じる処理が上限(暫定 {HwTestThresholds.CloseLimit.TotalMilliseconds:0} ms)を超えました: {elapsed.TotalMilliseconds:0} ms"); - c.Expect(steps.SequenceEqual(new[] { "closing", "timer", "camera", "light" }), + c.Expect(steps.SequenceEqual(HwTestJudge.CloseSequence), $"後始末の順序が不正です: {string.Join(" → ", steps)}(期待 closing → timer → camera → light)"); c.Expect(form.HwTestIsClosing, "閉じ始めのフラグが立っていません"); c.Expect(!form.HwTestRefreshTimerRunning, "本モニタの再描画タイマーが止まっていません"); @@ -970,6 +1002,9 @@ } else { c.Info("露光・ゲイン: 変更なし(または検査中に元に戻し済み)"); } + if (session.Options.WithChart) { + c.Info("ホワイトバランス: 校正で変わったまま戻していません(カメラ側の値だけが変わり,次の接続でデバイス状態ファイルの値に戻ります.デバイス状態ファイルには書き込みません)"); + } if (session.CameraConnected) { SetStep(session, "カメラ切断"); diff --git a/TIASshot/SelfTest/PatchColorError.cs b/TIASshot/SelfTest/PatchColorError.cs new file mode 100644 index 0000000..5c3c935 --- /dev/null +++ b/TIASshot/SelfTest/PatchColorError.cs @@ -0,0 +1,222 @@ +#if SELFTEST +using System; +using OpenCvSharp; + +namespace TIASshot { + + /// + /// 色票の測定値と参照値(tcc_srgb)の差の集計結果(SELFTEST 限定.セルフテストと実機テストで共通). + /// + internal struct PatchColorErrorStats { + /// 色票の数(行数) + public int Patches; + /// チャネルごとの差の絶対値の平均(8bit 階調.全色票 × 3 チャネルの平均) + public double MeanAbs; + /// チャネルごとの差の絶対値の最大(8bit 階調) + public double MaxAbs; + /// 最大の差が出た色票(0 始まり.無ければ -1) + public int MaxAbsPatch; + /// 色差 ΔE(CIE76.sRGB(D65)→ CIELAB)の平均(参考) + public double MeanDeltaE; + /// 色差 ΔE(CIE76)の最大(参考) + public double MaxDeltaE; + /// ΔE の最大が出た色票(0 始まり.無ければ -1) + public int MaxDeltaEPatch; + } + + /// + /// 色票の測定値と参照値の差を求める純粋関数(SELFTEST 限定.セルフテストの calib-save と実機テストの calibrate から使う.層①でテストする). + /// 値の並びは OpenCV の画像と tcc_srgb.csv に合わせて 1 行 = 1 色票,列 0 = B・列 1 = G・列 2 = R(8bit 階調 0〜255). + /// + internal static class PatchColorError { + + /// + /// 測定値と参照値の差を集計する.差の絶対値の平均・最大(チャネルごと)と,参考の ΔE(CIE76)の平均・最大を返す. + /// 平均の分母は 色票の数 × 3(セルフテストの従来の計算と同じ順序・同じ値). + /// NaN を含む場合,平均・最大は NaN になる(最大の色票は NaN が出た色票). + /// + /// 測定値(n×3.BGR) + /// 参照値(n×3.BGR) + /// 引数が null の場合 + /// 形状が n×3 でない・両者の行数が違う・0 行の場合 + public static PatchColorErrorStats Compare(double[,] measuredBgr, double[,] referenceBgr) { + if (measuredBgr == null) throw new ArgumentNullException(nameof(measuredBgr)); + if (referenceBgr == null) throw new ArgumentNullException(nameof(referenceBgr)); + if (measuredBgr.GetLength(1) != 3 || referenceBgr.GetLength(1) != 3) { + throw new ArgumentException($"色票の値は n×3(BGR)にしてください(測定 {measuredBgr.GetLength(0)}x{measuredBgr.GetLength(1)},参照 {referenceBgr.GetLength(0)}x{referenceBgr.GetLength(1)})"); + } + var n = measuredBgr.GetLength(0); + if (n == 0 || referenceBgr.GetLength(0) != n) { + throw new ArgumentException($"色票の数が不正です(測定 {n},参照 {referenceBgr.GetLength(0)})"); + } + + double sumErr = 0, maxErr = 0, sumDe = 0, maxDe = 0; + int maxErrPatch = -1, maxDePatch = -1; + for (var i = 0; i < n; i++) { + for (var j = 0; j < 3; j++) { + var err = Math.Abs(measuredBgr[i, j] - referenceBgr[i, j]); + sumErr += err; + if (maxErrPatch < 0 || err > maxErr || double.IsNaN(err)) { // NaN は最大を NaN にする(Math.Max と同じ扱い.誤差を見逃さない) + maxErr = err; + maxErrPatch = i; + } + } + var de = DeltaE76(measuredBgr[i, 0], measuredBgr[i, 1], measuredBgr[i, 2], + referenceBgr[i, 0], referenceBgr[i, 1], referenceBgr[i, 2]); + sumDe += de; + if (maxDePatch < 0 || de > maxDe || double.IsNaN(de)) { + maxDe = de; + maxDePatch = i; + } + } + return new PatchColorErrorStats { + Patches = n, + MeanAbs = sumErr / (n * 3), + MaxAbs = maxErr, + MaxAbsPatch = maxErrPatch, + MeanDeltaE = sumDe / n, + MaxDeltaE = maxDe, + MaxDeltaEPatch = maxDePatch, + }; + } + + /// + /// 1 つの色票のチャネルごとの差の絶対値の最大(8bit 階調).NaN を含む場合は NaN. + /// + /// 引数が null の場合 + /// 形状が n×3 でない・両者の行数が違う場合 + /// patch が範囲外の場合 + public static double PatchMaxAbs(double[,] measuredBgr, double[,] referenceBgr, int patch) { + CheckShape(measuredBgr, referenceBgr); + if (patch < 0 || patch >= measuredBgr.GetLength(0)) throw new ArgumentOutOfRangeException(nameof(patch), patch, "色票の番号が範囲外です"); + var max = 0.0; + for (var j = 0; j < 3; j++) { + var err = Math.Abs(measuredBgr[patch, j] - referenceBgr[patch, j]); + if (double.IsNaN(err)) return double.NaN; + if (err > max) max = err; + } + return max; + } + + /// + /// 指定した色票を除いた全色票のチャネルごとの差の絶対値の最大(8bit 階調).NaN を含む場合は NaN. + /// 除いた後に色票が残らない場合(1 行で除外した場合)は NaN. + /// + /// 除く色票(0 始まり.範囲外なら何も除かない) + /// 最大が出た色票(0 始まり.無ければ -1) + /// 引数が null の場合 + /// 形状が n×3 でない・両者の行数が違う場合 + public static double MaxAbsExcluding(double[,] measuredBgr, double[,] referenceBgr, int excludedPatch, out int maxPatch) { + CheckShape(measuredBgr, referenceBgr); + maxPatch = -1; + var max = double.NaN; + for (var i = 0; i < measuredBgr.GetLength(0); i++) { + if (i == excludedPatch) continue; + var err = PatchMaxAbs(measuredBgr, referenceBgr, i); + if (maxPatch < 0 || err > max || double.IsNaN(err)) { + max = err; + maxPatch = i; + if (double.IsNaN(err)) return double.NaN; + } + } + return max; + } + + /// + /// 参照値から白色票を決める(BGR の和が最も大きい色票.同じ値なら先の色票.tcc_srgb.csv では 13 番=0 始まりの 12). + /// 参照 CSV の行の並びに依存させず,番号をマジックナンバーにしないため. + /// + /// 白色票(0 始まり) + /// 引数が null の場合 + /// 形状が n×3 でない・0 行・NaN を含む場合 + public static int WhitePatchIndex(double[,] referenceBgr) { + if (referenceBgr == null) throw new ArgumentNullException(nameof(referenceBgr)); + if (referenceBgr.GetLength(1) != 3 || referenceBgr.GetLength(0) == 0) { + throw new ArgumentException($"参照値は n×3(BGR.1 行以上)にしてください({referenceBgr.GetLength(0)}x{referenceBgr.GetLength(1)})"); + } + var best = -1; + var bestSum = 0.0; + for (var i = 0; i < referenceBgr.GetLength(0); i++) { + var sum = referenceBgr[i, 0] + referenceBgr[i, 1] + referenceBgr[i, 2]; + if (double.IsNaN(sum)) throw new ArgumentException($"参照値に NaN があります({i + 1} 番)"); + if (best < 0 || sum > bestSum) { + best = i; + bestSum = sum; + } + } + return best; + } + + private static void CheckShape(double[,] measuredBgr, double[,] referenceBgr) { + if (measuredBgr == null) throw new ArgumentNullException(nameof(measuredBgr)); + if (referenceBgr == null) throw new ArgumentNullException(nameof(referenceBgr)); + if (measuredBgr.GetLength(1) != 3 || referenceBgr.GetLength(1) != 3 || measuredBgr.GetLength(0) != referenceBgr.GetLength(0)) { + throw new ArgumentException($"色票の値は同じ行数の n×3(BGR)にしてください(測定 {measuredBgr.GetLength(0)}x{measuredBgr.GetLength(1)},参照 {referenceBgr.GetLength(0)}x{referenceBgr.GetLength(1)})"); + } + } + + /// + /// 2 色の色差 ΔE(CIE76.CIELAB でのユークリッド距離).入力は sRGB の 8bit 階調(BGR の順). + /// + public static double DeltaE76(double b1, double g1, double r1, double b2, double g2, double r2) { + SrgbToLab(r1, g1, b1, out var l1, out var a1, out var bb1); + SrgbToLab(r2, g2, b2, out var l2, out var a2, out var bb2); + var dl = l1 - l2; + var da = a1 - a2; + var db = bb1 - bb2; + return Math.Sqrt(dl * dl + da * da + db * db); + } + + /// + /// sRGB(8bit 階調 0〜255.IEC 61966-2-1.白色点 D65)を CIELAB(D65)に変換する. + /// 0〜255 の外の値(変換行列の結果は範囲外になりうる)は丸めずに,ガンマの式を符号つきで延長して扱う + /// (範囲外の誤差を切り捨てて小さく見せないため). + /// + public static void SrgbToLab(double r, double g, double b, out double l, out double a, out double bLab) { + var rl = SrgbToLinear(r / 255.0); + var gl = SrgbToLinear(g / 255.0); + var bl = SrgbToLinear(b / 255.0); + // sRGB(D65)→ XYZ(IEC 61966-2-1 の行列) + var x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl; + var y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl; + var z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl; + // D65 の白色点で正規化 + var fx = LabF(x / 0.95047); + var fy = LabF(y / 1.00000); + var fz = LabF(z / 1.08883); + l = 116.0 * fy - 16.0; + a = 500.0 * (fx - fy); + bLab = 200.0 * (fy - fz); + } + + /// + /// 24×3(CV_64FC1)などの n×3 の Mat を配列にする(tcc_srgb.csv・TCC_sRGB{CN}.csv の読み込み結果用). + /// + /// n×3 の 1 チャネルの Mat でない場合 + public static double[,] ToArray(Mat m) { + if (m == null) throw new ArgumentNullException(nameof(m)); + if (m.Cols != 3 || m.Channels() != 1) throw new ArgumentException($"n×3 の 1 チャネルの Mat ではありません({m.Rows}x{m.Cols},{m.Channels()} チャネル)"); + var result = new double[m.Rows, 3]; + for (var i = 0; i < m.Rows; i++) { + for (var j = 0; j < 3; j++) { + result[i, j] = m.At(i, j); + } + } + return result; + } + + private static double SrgbToLinear(double v) { + var sign = v < 0 ? -1.0 : 1.0; + var abs = Math.Abs(v); + var lin = abs <= 0.04045 ? abs / 12.92 : Math.Pow((abs + 0.055) / 1.055, 2.4); + return sign * lin; + } + + private static double LabF(double t) { + const double delta = 6.0 / 29.0; + if (t > delta * delta * delta) return Math.Pow(t, 1.0 / 3.0); + return t / (3 * delta * delta) + 4.0 / 29.0; + } + } +} +#endif diff --git a/TIASshot/SelfTest/SelfTestRunner.cs b/TIASshot/SelfTest/SelfTestRunner.cs index c9139fe..d390adc 100644 --- a/TIASshot/SelfTest/SelfTestRunner.cs +++ b/TIASshot/SelfTest/SelfTestRunner.cs @@ -291,7 +291,7 @@ } } - private static bool IsUnder(string path, string root) { + internal static bool IsUnder(string path, string root) { if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(root)) return false; var p = Path.GetFullPath(path).TrimEnd('\\') + "\\"; var r = Path.GetFullPath(root).TrimEnd('\\') + "\\"; @@ -720,12 +720,7 @@ using (var converted = IoUtil.LoadMatFromCsv(Path.Combine(folder, convertedFile))) { if (!c.Expect(converted != null && converted.Rows == 24 && converted.Cols == 3, $"{convertedFile} を 24x3 として読み込めません")) continue; - var maxErr = 0.0; - for (var i = 0; i < 24; i++) { - for (var j = 0; j < 3; j++) { - maxErr = Math.Max(maxErr, Math.Abs(converted.At(i, j) - tccSrgb.At(i, j))); - } - } + var maxErr = PatchColorError.Compare(PatchColorError.ToArray(converted), PatchColorError.ToArray(tccSrgb)).MaxAbs; c.Expect(maxErr < 5.0, $"{ch} 次元: 色票の変換値が参照値から離れすぎ(最大誤差 {maxErr:0.00})", $"{ch} 次元: 変換行列(有限値)と色票の変換値(参照値との最大誤差 {maxErr:0.00})"); } @@ -750,20 +745,19 @@ if (!CheckImageFile(c, path, frameSize)) continue; // 変換画像上の色票が参照値(tcc_srgb)に戻っているか(JPEG 圧縮の誤差を許容) using (var img = Cv2.ImRead(path, ImreadModes.Color)) { - double sumErr = 0, maxErr = 0; + var measured = new double[24, 3]; for (var i = 0; i < 24; i++) { var roi = SelfTestChart.PatchRoi(i); var inner = new Rect(roi.X + 10, roi.Y + 10, roi.Width - 20, roi.Height - 20); using (var patch = new Mat(img, inner)) { var mean = Cv2.Mean(patch); - for (var j = 0; j < 3; j++) { - var err = Math.Abs(mean[j] - tccSrgb.At(i, j)); - sumErr += err; - maxErr = Math.Max(maxErr, err); - } + for (var j = 0; j < 3; j++) measured[i, j] = mean[j]; } } - var meanErr = sumErr / 72; + // 誤差の計算は実機テスト(calibrate)と共通(PatchColorError.平均は 24 色票 × 3 チャネル = 72 の平均) + var stats = PatchColorError.Compare(measured, PatchColorError.ToArray(tccSrgb)); + var meanErr = stats.MeanAbs; + var maxErr = stats.MaxAbs; c.Expect(meanErr < 6.0 && maxErr < 15.0, $"{Path.GetFileName(path)}: 色票が参照値に戻っていない(平均誤差 {meanErr:0.00},最大 {maxErr:0.00})", $"{Path.GetFileName(path)}: 保存・読み戻し OK,色票の参照値との誤差 平均 {meanErr:0.00} / 最大 {maxErr:0.00}"); diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index 4be5f51..4baefff 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -175,11 +175,16 @@ + + + + + diff --git a/TIASshot/UI/Form1.HwTest.cs b/TIASshot/UI/Form1.HwTest.cs index 84f28d2..7631ca4 100644 --- a/TIASshot/UI/Form1.HwTest.cs +++ b/TIASshot/UI/Form1.HwTest.cs @@ -176,6 +176,54 @@ /// 本モニタ(picDisplay)に表示中の画像 internal Image HwTestDisplayedImage => picDisplay.Image; + /// + /// 閉じる処理で後始末まで済んだ後に,次の検査のために画面の状態を接続直後に戻す(閉じる処理を 2 回以上検査するため). + /// 閉じ始めのフラグを下ろし(以降の ShowMessage・EnableShots を通常どおり効かせる),撮影ボタンを無効にし, + /// 本モニタの再描画タイマーを動かし直し,光源のポートを開き直して点灯する(StartSession と同じ状態. + /// StartSession を 2 回呼ぶとタイマーの Tick の登録と副モニタが重複するため,ここで必要な分だけ戻す). + /// カメラは接続しない(呼び出し側が HwTestConnect で接続する). + /// + /// 光源のポートを開き直せたら true + internal bool HwTestResumeAfterClose() { + _isClosing = false; + EnableShots(false); + _mainRefreshTimer.Start(); + // 開いたままのポートを開き直すと SerialPort が例外を投げるため,閉じているときだけ開く + if (_light.IsOpen) return true; + return HwTestReopenLight(); + } + + // ================================================================================ + // 撮影 + // ================================================================================ + + /// データ名(txtDataName.保存フォルダ名の一部)を設定する + internal void HwTestSetDataName(string dataName) { + txtDataName.Text = dataName; + } + + /// 連続撮影の枚数・間隔(ms)の入力欄を設定する + internal void HwTestSetMultiShots(int numShots, int intervalMs) { + txtNumMultiShots.Text = numShots.ToString(System.Globalization.CultureInfo.InvariantCulture); + txtMultiShotsInterval.Text = intervalMs.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + /// 撮影ボタン(単発・連続)が両方とも有効か + internal bool HwTestShotsEnabled => btnShotOne.Enabled && btnShotMulti.Enabled; + + /// ステータス表示(txtMessage)の現在値 + internal string HwTestMessage => txtMessage.Text; + + /// 単発撮影のボタンを押す(通常の操作と同じ処理.撮影は UI スレッドで同期に行われ,保存は保存スレッドで続く) + internal void HwTestClickShotOne() { + btnShot1_Click(btnShotOne, EventArgs.Empty); + } + + /// 連続撮影のボタンを押す(通常の操作と同じ処理) + internal void HwTestClickShotMulti() { + btnShotMulti_Click(btnShotMulti, EventArgs.Empty); + } + // ================================================================================ // フレームの観測 // ================================================================================ 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 cc0e245..bff6b75 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" @@ -151,7 +151,7 @@ ## 実機テスト (Hardware Test) -実機のカメラと光源を操作して,接続・プレビュー・露光/ゲイン・光源・閉じる処理・再接続を検査する(`Debug|x64` のみ.[ENV_06](ENV_06_ビルド構成とビルドフラグ.md) の `HWTEST`.計画は [PLAN_06](../03_PLAN/PLAN_06_実機自動テスト計画.md)).実際の `Form1` が表示される. +実機のカメラと光源を操作して,接続・プレビュー・露光/ゲイン・光源・閉じる処理・再接続と,`--with-chart` では実物の舌診チャートでの校正・撮影と保存・保存中/校正中に閉じる処理を検査する(`Debug|x64` のみ.[ENV_06](ENV_06_ビルド構成とビルドフラグ.md) の `HWTEST`.計画は [PLAN_06](../03_PLAN/PLAN_06_実機自動テスト計画.md)).実際の `Form1` が表示される. **ユーザーの合図があるときだけ実行する**(撮影の本番中に機材を操作しないため.Claude は合図なしに実行しない). @@ -161,11 +161,17 @@ - 検査する機種のカメラと光源が開発 PC に接続されている - カメラの前の穴にカラーチャート(または白板)がはめてある(チャート不要の検査の間は自動校正を止める) - TIASshot が起動していない(起動中ならランナーは実行せずに終了コード 2 を返す) +- **実行中は TIASshot のウィンドウを操作しない**(閉じると検査が戻らずタイムアウトしうる).重いアプリが動いていると校正・保存が遅くなる(保存の待ちの上限は 180 秒) ```powershell -# ビルド(Debug|x64)→ 実機テスト(結果は .verify/hwtest/run-<日時>/).2 機種とも行う場合は機種ごとに実行する -python scripts/verify/run_hwtest.py --camera lucam +# ビルド(Debug|x64)→ 実機テスト(結果は .verify/hwtest/run-<日時>/).対象は ImagingSource だけ python scripts/verify/run_hwtest.py --camera iscam +# 続けてチャートが必要な検査(カラーチャートをはめたまま.チャート不要の検査は行わないため上の行と両方実行する) +python scripts/verify/run_hwtest.py --camera iscam --with-chart +# 合格時も作業フォルダを残し,--with-chart では証拠の画像(evidence\*.png)も保存する +python scripts/verify/run_hwtest.py --camera iscam --with-chart --keep +# Lumenera(通常は行わない.PLAN_06「対象機種」) +python scripts/verify/run_hwtest.py --camera lucam # 開発中の素早い再確認(Debug 出力を消さない.判定には使わない) python scripts/verify/run_hwtest.py --camera iscam --no-clean @@ -175,10 +181,11 @@ 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-*`)を残す(不合格・タイムアウト時は常に残る). +- 書式: `python scripts/verify/run_hwtest.py --camera [--with-chart] [--keep] [--no-clean]`.`--with-chart`(チャートが必要な検査.PLAN_06 フェーズ 2)は `calibrate`・`shot`・`close-saving`・`close-calibrating` を行い(校正は止めない),チャート不要の検査(`preview`〜`reconnect`)は行わない(`connect`・`cleanup` は両方で行う).`--keep` は単体の `--hwtest-keep` を付けて実行する. +- 単体の書式: `TIASshot.exe --hwtest --camera [--hwtest-out <結果ファイル>] [--with-chart] [--hwtest-keep]`.`--hwtest-keep` を付けると合格時も作業フォルダ(`%TEMP%\TIASshot-hwtest\work-*`)を残し(不合格・タイムアウト時は常に残る),`--with-chart` では校正後のフレームとその変換画像(`evidence\calibrated-frame-raw.png`・`evidence\calibrated-frame-srgbNN.png`)も保存する.`--with-chart` の撮影データ・校正データは作業フォルダの `data\` に保存される(本番の保存先には書かない). +- 所要時間(ビルドを除いた検査の部分): チャート不要の検査 約 30〜40 秒,`--with-chart` 約 50〜55 秒. - 出力先: `.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 秒で打ち切った. +- ランナーの終了コード: `0`=全項目合格,`1`=不合格あり(ビルド失敗・検査の不合格・タイムアウト),`2`=前提条件を満たさない(ツール未導入・TIASshot 起動中・指定機種が見つからない・引数不正). +- 実機テスト単体の終了コード(`summary.json` の `hwtest_exit_code`): `0`=全項目合格,`1`=不合格あり,`2`=実機テスト自体の異常(引数不正・指定機種が見つからない),`3`=タイムアウト(各項目の上限または全体(チャート不要 600 秒・`--with-chart` 1200 秒).ランナーでは `1` になる),`null`=ランナーが打ち切った(チャート不要 720 秒・`--with-chart` 1320 秒). - NUnit テスト・セルフテストは実行しない(それぞれ `run_tests.py`・`run_selftest.py`). - 検査項目・しきい値・判定の読み方は `.claude/verify-profile.md` の「実機テスト」節と [TEST_01](../06_TEST/TEST_01_テスト仕様.md) を参照する. 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 75496d2..37209ae 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" @@ -12,13 +12,16 @@ | --- | --- | | 接続 | カメラ(ImagingSource・Lumenera)と光源は,Claude が動作している開発 PC に接続できる | | 機材 | 両機種がそろっている | +| 対象機種 | **ImagingSource だけを対象にし,Lumenera の実機テストは行わない**(2026-10-08 にユーザーが決定.Lumenera は解像度が相対的に低く今後あまり使わない見込みのため).`--camera lucam` の実装は残すが,検査の対象にしない | | 実行してよい条件 | **ユーザーが会話の中で明示的に合図したときだけ**.撮影の本番中に機材を操作しないため,合図なしに実機テストを実行しない | ## 仕組み (Design) 既存のセルフテスト(`TIASshot.exe --selftest`.偽カメラ・ヘッドレス)と同じ考え方で,実機版の検査モードを追加する. -- **起動**: `TIASshot.exe --hwtest --camera [--with-chart] --hwtest-out <結果ファイル>`.ランナー `scripts/verify/run_hwtest.py` がビルドから実行・判定までを行う(`run_selftest.py` と同じ形) +- **起動**: `TIASshot.exe --hwtest --camera [--with-chart] [--hwtest-keep] --hwtest-out <結果ファイル>`.ランナー `scripts/verify/run_hwtest.py` がビルドから実行・判定までを行う(`run_selftest.py` と同じ形) +- **`--with-chart`**: チャートが必要な検査(connect → calibrate → shot → close-saving → close-calibrating → cleanup)を行う.校正は止めない.チャート不要の検査は行わないため,`--camera iscam` と `--camera iscam --with-chart` をカラーチャートをはめたまま続けて実行する.全体の上限はチャート不要 600 秒・`--with-chart` 1200 秒,ランナーの打ち切りはそれぞれ 720 秒・1320 秒 +- **`--keep`(ランナー)= `--hwtest-keep`**: 合格時も作業フォルダ(`%TEMP%\TIASshot-hwtest\work-*`)を残す(不合格・タイムアウト時は常に残る).`--with-chart` では校正後のフレームとその変換画像を証拠の画像(`evidence\calibrated-frame-raw.png`・`evidence\calibrated-frame-srgbNN.png`.`NN` は変換の次元)として保存する.撮影データ・校正データは作業フォルダの `data\` に保存され,合格時は作業フォルダごと削除される - **ビルドフラグ**: `HWTEST`(Debug 系の構成のみ).診断・評価機能を Release に含めない方針(CLAUDE.md・[ENV_06](../02_ENV/ENV_06_ビルド構成とビルドフラグ.md))に従う - **フォーム**: ImagingSource は SDK の画面部品(`ICImagingControl`)を使うため,ヘッドレスではなく**実際の `Form1` を表示して**動かす.人間がその場にいる前提なので表示してよい - **カメラの選択**: 両機種を同時につないでも,`--camera` で指定した機種だけを使う(通常起動の `Form1_Load` は Lumenera を優先して探すため,そのままでは ImagingSource を検査できない) @@ -54,20 +57,20 @@ ### チャートが必要(`--with-chart`.人間がチャートをカメラの前に置いてから実行する) -※ チャート不要の検査と同じくカラーチャートをはめたまま続けて実行できる(付け外しは不要).こちらは自動校正を走らせて結果を確かめる. +※ チャート不要の検査と同じくカラーチャートをはめたまま続けて実行できる(付け外しは不要).こちらは自動校正を走らせて結果を確かめる.`--with-chart` ではチャート不要の検査(`preview`〜`reconnect`)は行わない(`connect`・`cleanup` は共通.`cleanup` ではホワイトバランスを戻さない.校正で変わるのはカメラ側の値だけで,次の接続でデバイス状態ファイルの値に戻る). | ID | 確認内容 | 判定の仕方 | | --- | --- | --- | -| `calibrate` | 実物の舌診チャートで自動校正が完了する | 校正完了までの時間(上限つき)と,変換後の色票と参照値の色差 | -| `shot` | 単発・連続撮影で保存される | 保存されたファイル・Info.csv の中身,撮影時刻の間隔(設定値との差) | -| `close-saving` | 保存中に閉じようとすると取り消され,保存後は閉じられる | 取り消しの成否と表示文,保存完了後の後始末の順序 | -| `close-calibrating` | 校正中に閉じてもすぐ戻る | 後始末の所要時間と例外の有無 | +| `calibrate` | 実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値に近い | 校正の待ち時間(最初のフレーム → チャート検出 → 校正完了.上限 60 秒),校正完了後に撮影ボタンが有効になること,校正データが一時フォルダに保存されること.校正後のフレームを校正の変換行列で変換し,24 色票の平均と参照値(`tcc_srgb`)の差を**保存画像と同じ 8bit(切り詰め後)・チャネルごとの差の絶対値**で判定する(平均,白色票を除いた最大,白色票の最大).白色票は参照値の BGR の和が最大の色票(13 番).ΔE76・白飛びの割合・当てはめの誤差は参考 | +| `shot` | 単発・連続撮影で保存される | 単発 1 枚・連続 3 枚(間隔 1000 ms).撮影直後は保存中で撮影ボタンが無効,保存完了(上限つき)後に再有効化されること,保存されたファイル(撮影画像・変換画像・Info.csv)と撮影ログの行数・備考,撮影時刻の間隔(設定値との差),エラー通知の有無 | +| `close-saving` | 保存中に閉じようとすると取り消され,保存後は閉じられる | 連続 5 枚(間隔 0 ms.保存が先に終わったら 10 枚で再試行)の保存中に閉じる処理を呼び,取り消しの成否と表示文・後始末が走っていないこと.保存完了後に閉じる処理の所要時間・後始末の順序(closing → timer → camera → light)・閉じた後に届いたフレーム数 | +| `close-calibrating` | 校正中に閉じてもすぐ戻る | 再接続して校正中になってから 500 ms 後に閉じる処理を呼び,所要時間・後始末の順序・例外の有無・閉じた後に届いたフレーム数 | -※ 判定のしきい値(色差の上限,撮影間隔の許容誤差等)は,最初の実行で実測値を見てから決める.チャート不要の検査のしきい値はフェーズ 1 で決めた(下記「確定したしきい値と実測値」). +※ 判定のしきい値はチャート不要の検査はフェーズ 1,チャートが必要な検査はフェーズ 2 の実測から決めた(下記「確定したしきい値と実測値」). ### 確定したしきい値と実測値 (Thresholds and Measurements) -フェーズ 1 の実測(ImagingSource DFK 33UX178.シリアル 35620307・10620446.カラーチャートをはめた状態)から決めた.しきい値の本体は `HwTest/HwTestJudge.cs`. +フェーズ 1 の実測(ImagingSource DFK 33UX178.シリアル 35620307・10620446.カラーチャートをはめた状態)と,フェーズ 2 の実測(同機種.シリアル 10620446.2026-10-08 の 2 回)から決めた.しきい値の本体は `HwTest/HwTestJudge.cs`. | 項目 | しきい値 | 実測値 | | --- | --- | --- | @@ -80,12 +83,30 @@ | `close-preview` の閉じる処理 | 1000 ms 以内,閉じた後に届くフレーム 2 以下 | 75〜221 ms,0 フレーム | | `reconnect` | 再接続後に 5 フレーム以上 | 964 ms で 5 フレーム(2026-10-08 13:38 の実行) | | 露光・ゲインの復元 | 元の値との相対誤差 1% 以内 | 一致 | +| `calibrate` の校正の待ち時間 | 60 秒以内 | 5.0〜5.6 秒(チャート検出まで 1.6〜1.8 秒+校正 3.4〜3.8 秒) | +| `calibrate` の色の誤差(8bit・チャネルごとの \|測定−参照\|.17 次元)の平均 | 5 以下 | 1.57 | +| `calibrate` の色の誤差の白色票を除いた最大 | 10 以下 | 3.47〜3.5(10 番) | +| `calibrate` の色の誤差の白色票(13 番)の最大 | 40 以下(**暫定**.下記「17 次元の変換のノイズ」) | 24.65〜27.35 | +| `calibrate` の ΔE76 | 参考(判定しない) | 平均 1.20〜1.26,最大 10.11〜12.04(白色票) | +| `shot` の撮影間隔のずれ | 設定値との差 100 ms 以下 | 4〜22 ms | +| `shot`・`close-saving` の保存の待ち時間 | 180 秒以内(2026-10-09 に 90 秒から延ばした) | 1 枚あたり約 3.3 秒(5 枚で 16〜20 秒).重いアプリが動いていると約 11 秒(5 枚で 55 秒) | +| `close-saving` の閉じる処理 | 保存中は取り消される.保存の完了後は 1000 ms 以内,閉じた後に届くフレーム 2 以下 | 取り消しを確認,33〜72 ms,0 フレーム | +| `close-calibrating` の閉じる処理 | 校正中になるまで 60 秒以内,閉じる処理 1000 ms 以内,閉じた後に届くフレーム 2 以下 | 53〜98 ms,0 フレーム | + +### 17 次元の変換のノイズ (Noise of the 17-Term Conversion) + +フェーズ 2 の実機テストで,白色票の色の誤差だけが大きい(24.65〜27.35.他の色票は 3.5 以下)ことが分かった.**製品の性質であり,対策は要判断**. + +- 事実: 白色票の入力(約 226.標準偏差 3〜5.飽和なし)が 17 次元(3 次)の変換で約 10 倍に増幅され(変換後の標準偏差 約 50,8bit に切り詰める前の値 -249〜621),8bit の範囲を外れて点状のノイズ・白飛びになる(白色票の 255 の画素 約 73%).チャート外の白い台紙・枠でも点状のノイズが出る.平均の色は合っている(切り詰め前の値で平均すると参照値との差は 2 以内).白など色票の範囲の端の明るい色で,高次の多項式の外挿がノイズを増幅するためと考えられる +- **回帰ではない(上流 v1.6 でも同じ)**: 2026-10-09 に v1.6 と現行で同じチャートを撮影し,同じ入力を両方のコードに通すと,マスク・色票の値・変換行列・変換後の画像(PNG/JPEG)がビット単位で一致した(色変換はリファクタリングで変わっていない).白色票の変換後の標準偏差も v1.6 51/39/23・現行 50/36/24(B/G/R)で同程度 +- 要判断: 対策(使う次元を下げる,入力・出力に制限をかける,色票の範囲外を別扱いにする等)は色の再現性に関わるため,研究室・上流と相談して決める.それまで白色票のしきい値 40 は暫定とし,色の誤差は白色票とそれ以外に分けて判定する +- 付随して確かめたこと: CSV に保存する変換行列(小数 7 桁)は 17 次元の 3 次の係数では有効数字が約 3 桁しかなく,CSV から画像を再現すると 8bit で最大 2〜4 ずれる ## 進め方 (Workflow) 1. 人間が機材をつなぎ,カラーチャート(または白板)を穴にはめて,「実機テストして」等と合図する -2. Claude(verifier)が `python scripts/verify/run_hwtest.py --camera <機種> [--with-chart]` を実行し,結果ファイルと終了コードで判定する -3. 2 機種とも行う場合は,機種ごとに実行する +2. Claude(verifier)が `python scripts/verify/run_hwtest.py --camera <機種>` と `python scripts/verify/run_hwtest.py --camera <機種> --with-chart` を続けて実行し,結果ファイルと終了コードで判定する +3. 対象は ImagingSource(`--camera iscam`)だけ.Lumenera は行わない(上記「対象機種」) 4. 人間に残る確認は,画面の見た目の最終確認など,数値で置き換えられないものだけにする ## 変更が必要なもの (Changes) @@ -106,15 +127,17 @@ - `HWTEST` フラグ,`--hwtest`,機種指定の接続,結果ファイル,ランナーを作る - チャート不要の検査(`connect`・`preview`・`exposure-gain`・`light`・`close-preview`・`reconnect`)を実装する - 検証プロファイルを更新する -- 完了条件: 両機種で人間の合図のもと実行でき,しきい値を実測から決めて全項目が合格する -- **状態: ImagingSource で完了(2026-10-08.DFK 33UX178 の 2 個体で 7/7 合格).Lumenera は機材がつながり次第実行する** +- 完了条件: ImagingSource で人間の合図のもと実行でき,しきい値を実測から決めて全項目が合格する(当初は両機種としていたが,2026-10-08 に Lumenera を対象外とした) +- **状態: 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: チャートが必要な検査 - `calibrate`・`shot`・`close-saving`・`close-calibrating` を実装する -- 完了条件: 両機種で実物のチャートを使って全項目が合格する +- 完了条件: ImagingSource で実物のチャートを使って全項目が合格する +- **状態: ImagingSource で完了(2026-10-08.DFK 33UX178(シリアル 10620446)で 2 回とも 6/6 合格)** +- 実機テストで見つかったこと: 17 次元の変換で白色票・白い台紙に点状のノイズ・白飛びが出る(上流 v1.6 から同じ.上記「17 次元の変換のノイズ」.対策は要判断) ### フェーズ 3: 手動テストの縮小 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 2084455..fca7357 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,7 +10,7 @@ - 結合ロジックテスト: 偽カメラとヘッドレス Form1 で,撮影・保存の流れと失敗時の通知を NUnit で自動確認する(`[Category("Integration")]`.方針は `.claude/rules/testing.md`,移行計画は [PLAN_05](../03_PLAN/PLAN_05_テスト網羅性向上計画.md)) - 回帰テスト: リファクタリング前後の出力が一致することを確認する - セルフテスト: 実機なしで依存ロード・プレビュー経路・校正〜保存を自動確認する(手動テストの前段) -- 実機テスト: Claude が実機のカメラと光源を操作して,接続・プレビュー・露光/ゲイン・光源・閉じる処理・再接続を自動確認する(ユーザーの合図があるときだけ.手動テストの前段) +- 実機テスト: Claude が実機のカメラと光源を操作して,接続・プレビュー・露光/ゲイン・光源・閉じる処理・再接続と,実物の舌診チャートでの校正・撮影と保存・保存中/校正中に閉じる処理を自動確認する(ユーザーの合図があるときだけ.手動テストの前段) - 手動テスト: 実機を使って動作を目視確認する ## セルフテスト (Self Test) @@ -25,9 +25,12 @@ 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 機種とも行う場合は機種ごとに実行する) +- `python scripts/verify/run_hwtest.py --camera `(対象は ImagingSource の `--camera iscam` だけ.Lumenera の実機テストは行わない.PLAN_06「対象機種」) +- 続けて `python scripts/verify/run_hwtest.py --camera iscam --with-chart`(チャートが必要な検査.カラーチャートをはめたまま実行する.`--with-chart` ではチャート不要の検査を行わないため,両方を実行する) - **実行の前提**: カメラ・光源のドライバが入っている([ENV_02](../02_ENV/ENV_02_環境構築手順.md)),検査する機種のカメラと光源が接続されている,カメラの前の穴にカラーチャート(または白板)がはめてある,TIASshot が起動していない -- **合格条件**: 終了コード 0(下表の 7 項目が PASS.結果は `.verify/hwtest/run-<日時>/hwtest-result.txt`) +- **合格条件**: 両方の実行で終了コード 0(チャート不要は下表の 7 項目,`--with-chart` は `connect`・`calibrate`・`shot`・`close-saving`・`close-calibrating`・`cleanup` の 6 項目が PASS.結果は `.verify/hwtest/run-<日時>/hwtest-result.txt`) + +チャート不要(`--with-chart` なし): | 項目 | 確認内容 | 合格条件 | | --- | --- | --- | @@ -39,8 +42,18 @@ | `reconnect` | 切断した後に再接続できる | 2 回目の `Connect()` が成功し,5 フレーム以上届く | | `cleanup` | 後始末と書き込み禁止 | 露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯しない)が成功し,設定・デバイス状態ファイル・本番の保存先が変わっていない | -- 2026-10-08 に ImagingSource(DFK 33UX178 の 2 個体)で 7/7 合格した.Lumenera は未実施 -- チャートが要る項目(校正・撮影・保存中や校正中に閉じる.PLAN_06 フェーズ 2)は未実装のため,下記の手動テストで確認する +チャートが必要(`--with-chart`.`connect`・`cleanup` は上表と同じ): + +| 項目 | 確認内容 | 合格条件 | +| --- | --- | --- | +| `calibrate` | 実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値に近い | 60 秒以内に校正が完了し,撮影ボタンが有効になる.校正後のフレームを変換した 24 色票と参照値(`tcc_srgb`)の差(8bit・チャネルごとの差の絶対値)が平均 5 以下,白色票(13 番)を除いた最大 10 以下,白色票の最大 40 以下(暫定.PLAN_06「17 次元の変換のノイズ」) | +| `shot` | 単発・連続撮影で保存される | 単発 1 枚・連続 3 枚(間隔 1000 ms)で撮影画像・sRGB 変換画像・Info.csv がそろい,撮影ログの行数が撮影枚数と一致する.保存が 90 秒以内に終わって撮影ボタンが再有効化され,撮影時刻の間隔と設定値の差が 100 ms 以下,エラー通知がない | +| `close-saving` | 保存中に閉じようとすると取り消され,保存後は閉じられる | 保存中の閉じる処理が取り消され(表示「保存中です.保存完了後に閉じてください」・後始末が走らない),保存の完了後の閉じる処理が 1000 ms 以内・後始末の順序が closing → timer → camera → light・閉じた後に届くフレームが 2 以下 | +| `close-calibrating` | 校正中に閉じてもすぐ戻る | 再接続して校正中になり(60 秒以内),閉じる処理が 1000 ms 以内に戻り,例外がなく,閉じた後に届くフレームが 2 以下 | + +- 2026-10-08 に ImagingSource(DFK 33UX178 の 2 個体)で 7/7 合格した.Lumenera の実機テストは行わない(解像度が相対的に低く今後あまり使わない見込みのため) +- 2026-10-08 に ImagingSource(DFK 33UX178.シリアル 10620446)で `--with-chart` を 2 回実行し,いずれも 6/6 合格した +- 変換画像の見た目(白色票や白い台紙の点状のノイズ・白飛び等)は数値で判定しきれないため,下記の手動テストで確認する ## 結合ロジックテスト (Integration Logic Tests) @@ -136,12 +149,14 @@ - ※ フレームが流れることは実機テスト(`preview`)でも確認できる.見た目は手動で確認する - 舌診チャートを映すとキャリブレーションが自動開始される - キャリブレーション完了後に撮影ボタンが有効になる + - ※ 校正の完了と撮影ボタンの有効化は実機テスト(`calibrate`)でも確認できる - 1枚撮影が正常に完了し,ファイルが保存される - 連続撮影が正常に完了し,ファイルが保存される + - ※ 保存されるファイルと撮影間隔は実機テスト(`shot`)でも確認できる.変換画像の見た目は手動で確認する - 光源の ON/OFF が切り替わる - ※ 実機テスト(`light`)でも確認できる - アプリを閉じてもエラーが出ない(閉じた後も光源は点いたまま残るのが仕様) - - ※ プレビュー中に閉じる場合は実機テスト(`close-preview`)でも確認できる + - ※ プレビュー中・保存中・校正中に閉じる場合は実機テスト(`close-preview`・`close-saving`・`close-calibrating`)でも確認できる **合格条件** diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4c6da3d..2fdd014 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -197,3 +197,11 @@ - 仕様変更: アプリ終了時に光源を消灯しない(ポートを閉じるだけ.上流 v1.7 と同じ)とユーザーが確定した. - 失敗パターン: ① 同梱の `DFK33UX178.xml` は別個体のシリアルを持ち,旧 `IScam.Connect` がファイルのデバイスを開いていたため,手元の個体では接続できず例外が外へ漏れた.機種名でデバイスを開いてから設定だけ適用する形に直した(本番の接続にも効く修正).② 露光を整数の段(`VCDRangeProperty`)で扱っていたため,2^n でない元の露光(1/60 秒等)に戻せていなかった.絶対値で扱う形に改めた.③ Release の exe を作り直さずに確認し,古い動作を見て判断を誤りかけた.確認は必ずランナー(Debug をビルドし直す)経由で行う. - 後段: Lumenera は機材が未接続で未実施(つながり次第実行する).フェーズ 2(チャートが要る検査).本番の Info.csv の露光記録が整数の段を「1/10 して ms」として書いており誤った値になっている疑い(未確認.報告台帳に記録). + +- [x] **Lumenera を実機テストの対象外に** (2026-10-08): Lumenera は解像度が相対的に低く今後あまり使わない見込みのため,実機テスト(PLAN_06)の対象を ImagingSource だけにした.`--camera lucam` の実装は残すが検査の対象にしない. + +- [x] **実機テストにチャートが必要な検査(PLAN_06 フェーズ 2)を追加し,ImagingSource の実機で合格** (2026-10-08〜09): `--with-chart` で校正(`calibrate`)・撮影と保存(`shot`)・保存中に閉じる(`close-saving`)・校正中に閉じる(`close-calibrating`)を検査できるようにした.DFK 33UX178(シリアル 10620446)で 2 回とも 6/6 合格し,しきい値を実測から決めた(校正の待ち時間 60 秒・色の誤差の平均 5 / 白色票を除いた最大 10 / 白色票 40(暫定)・撮影間隔のずれ 100 ms 等.PLAN_06).ランナーに `--keep`(合格時も作業フォルダを残し,校正後のフレームとその変換画像を証拠として保存)を足した. + - 設計判断: ① `--with-chart` ではチャート不要の検査を行わない(校正を止めない検査と止める検査を 1 回に混ぜると,校正でホワイトバランスが変わった後に露光・ゲインを評価することになるため).カラーチャートをはめたまま `--camera iscam` と続けて実行する.② 色の誤差は保存画像と同じ 8bit(切り詰め後)・チャネルごとの差の絶対値で判定し,ΔE76 は参考にした(実際に保存される画像の色で判定するため).③ 白色票(参照値の BGR の和が最大の色票)を分けて判定した.白色票だけが 17 次元の変換のノイズで大きく外れ,同じしきい値では他の色票の確認が甘くなるため. + - 発見: 17 次元(3 次)の変換が白色票の入力のばらつき(標準偏差 3〜5)を約 10 倍に増幅し(切り詰め前 -249〜621),8bit で点状のノイズ・白飛びになる.チャート外の白い台紙・枠でも出る.平均の色は合っている.回帰かを確かめるため,2026-10-09 に上流 v1.6 と現行で同じチャートを撮影し,それぞれの入力(TCC.png・撮影画像)を両方のコードに通して比べた.マスク・色票の値・変換行列・変換後の画像(PNG/JPEG)がビット単位で一致し,白色票の変換後の標準偏差も同程度(v1.6 51/39/23・現行 50/36/24)だったため,リファクタリングによる回帰ではなく上流からの性質と判断した.付随して,CSV に保存する変換行列(小数 7 桁)では 3 次の係数の有効数字が約 3 桁しかなく,CSV から画像を再現すると 8bit で最大 2〜4 ずれることを確かめた(報告台帳の「SaveMatToCsv の精度」の裏付け).同梱 `DFK33UX178.xml` は v1.6 版とホワイトバランス等の設定値・シリアル(v1.6: 27710698,現行: 41520093)が異なる. + - 失敗パターン: ① v1.6 を別フォルダから起動すると,作業フォルダが exe の場所でないため `config.xml` を読めずに落ちた(報告台帳の「config.xml をカレントディレクトリ基準で読む」を実際に踏んだ).exe のフォルダで起動する.② v1.6 は同梱のデバイス状態ファイルのシリアルの個体を開こうとするため,手元の別個体には接続できなかった.比較用に接続処理だけを差し替えた(色変換のコードには手を入れていない). + - 後段: 17 次元の変換のノイズの対策(使う次元を下げる・入力/出力に制限をかける等)は色の再現性に関わるため研究室・上流と相談して決める(それまで白色票のしきい値 40 は暫定.報告台帳に記録).PLAN_06 フェーズ 3(手動テストの縮小). diff --git a/scripts/verify/run_hwtest.py b/scripts/verify/run_hwtest.py index c5c279f..928e417 100644 --- a/scripts/verify/run_hwtest.py +++ b/scripts/verify/run_hwtest.py @@ -10,13 +10,18 @@ 4. TIASshot.exe --hwtest --camera <機種> を実行ファイルのフォルダで実行し,終了コードと結果ファイルで判定する (実際のフォームが表示される.保存先は %TEMP%\\TIASshot-hwtest\\work-* に差し替えられる) +--with-chart を付けると,チャート不要の検査(preview〜reconnect)の代わりにチャートが必要な検査 +(calibrate・shot・close-saving・close-calibrating.校正を止めない.カメラの前に舌診チャートがはまっている前提)を行う. +チャート不要の検査と続けて実行する(connect・cleanup は両方で行う). + 出力: .verify/hwtest/run-<日時>/ に build.log・hwtest-result.txt・summary.json 終了コード: 0=全項目合格,1=不合格あり(ビルド失敗・検査の不合格・タイムアウト), - 2=前提条件を満たさない(ツール未導入・TIASshot 起動中・指定機種が見つからない・引数不正・--with-chart(未実装)) + 2=前提条件を満たさない(ツール未導入・TIASshot 起動中・指定機種が見つからない・引数不正) 使い方(リポジトリルートで実行): python scripts/verify/run_hwtest.py --camera lucam python scripts/verify/run_hwtest.py --camera iscam + python scripts/verify/run_hwtest.py --camera iscam --with-chart # チャートが必要な検査 python scripts/verify/run_hwtest.py --camera iscam --no-clean # Debug 出力を消さない(開発時の短縮用) """ @@ -31,8 +36,9 @@ 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 分)より長くする.超えたらランナーがプロセスを打ち切る +# 実機テスト内部のウォッチドッグ(全体: チャート不要 10 分・--with-chart 20 分)より長くする.超えたらランナーがプロセスを打ち切る HWTEST_TIMEOUT_SEC = 720 +HWTEST_WITH_CHART_TIMEOUT_SEC = 1320 # 実機テスト(TIASshot.exe --hwtest)の終了コード HW_PASS = 0 @@ -53,7 +59,10 @@ 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 になる)") + help="チャートが必要な検査(calibrate・shot・close-saving・close-calibrating.PLAN_06 フェーズ 2)を行う" + "(チャート不要の検査は行わない.舌診チャートをはめてから実行する)") + parser.add_argument("--keep", action="store_true", + help="合格時も作業フォルダ(%%TEMP%%/TIASshot-hwtest/work-*)を残し,--with-chart では証拠の画像(evidence/*.png)も保存する(TIASshot.exe の --hwtest-keep)") parser.add_argument("--no-clean", action="store_true", help="Debug 出力(bin/obj の x64/Debug)を削除しない(開発時の短縮用)") args = parser.parse_args() @@ -64,7 +73,7 @@ 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, + summary: dict = {"run_dir": str(run_dir), "camera": args.camera, "with_chart": args.with_chart, "keep": args.keep, "steps": [], "result": None} def step(name: str, ok: bool, detail: str) -> None: @@ -94,13 +103,16 @@ result_file = run_dir / "hwtest-result.txt" cmd = [str(EXE), "--hwtest", "--camera", args.camera, "--hwtest-out", str(result_file)] + if args.keep: + cmd.append("--hwtest-keep") if args.with_chart: cmd.append("--with-chart") summary["command"] = " ".join(cmd) + timeout_sec = HWTEST_WITH_CHART_TIMEOUT_SEC if args.with_chart else HWTEST_TIMEOUT_SEC try: # デバイス状態ファイル({DeviceName}.xml)は相対パスで読まれるため,通常起動と同じく実行ファイルのフォルダで実行する proc = subprocess.run(cmd, cwd=EXE.parent, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - timeout=HWTEST_TIMEOUT_SEC, check=False) + timeout=timeout_sec, check=False) code = proc.returncode except subprocess.TimeoutExpired: code = None @@ -109,14 +121,14 @@ summary["hwtest_exit_code"] = code summary["hwtest_lines"] = heads if code is None: - detail = f"{HWTEST_TIMEOUT_SEC} 秒で終わらないためランナーが打ち切りました(hwtest-result.txt)" + detail = f"{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