#if SELFTEST
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Windows.Forms;
using System.Xml;
using OpenCvSharp;
using OpenCvSharp.Aruco;
using OpenCvSharp.Extensions;
namespace TIASshot {
/// <summary>
/// ヘッドレスのセルフテスト(SELFTEST 限定).<c>TIASshot.exe --selftest</c> で起動すると,
/// フォームを表示せずに次の 3 項目を実行し,終了コードと結果ファイル(+標準出力)で結果を返す.
/// <list type="number">
/// <item>deps: 依存アセンブリのロード(バインディングリダイレクト含む)と OpenCvSharp の最小実行</item>
/// <item>preview: 偽カメラ → Form1.ShowImage(FastClone)→ 本モニタ/副モニタの表示用 Bitmap の経路</item>
/// <item>calib-save: 合成チャートでチャート検出 → 色変換行列算出 → 撮影 → sRGB 変換画像の保存</item>
/// </list>
/// 実カメラ SDK には触れない.保存先は一時フォルダに差し替え,ユーザーの設定・データは書き換えない.
///
/// 引数:
/// --selftest セルフテストを実行する
/// --selftest-out <path> 結果ファイルの出力先(省略時は %TEMP%\TIASshot-selftest\result-<日時>.txt)
/// --selftest-keep 合格時も作業フォルダ(保存画像・CSV)を削除しない
/// 終了コード: 0=全項目合格,1=不合格あり,2=セルフテスト自体の異常,3=タイムアウト
///
/// 【注意】本クラスのメソッド本体・フィールドでは OpenCvSharp 等の依存型を直接使わず,
/// 各検査はラムダ(別メソッドとして遅延 JIT される)の中で使う.依存 DLL が欠けていても
/// JIT 失敗が検査単位の例外として捕捉され,どの依存が欠けたかを報告できるようにするため.
/// </summary>
internal static class SelfTestRunner {
public const string FlagSelfTest = "--selftest";
private const string FlagOut = "--selftest-out";
private const string FlagKeep = "--selftest-keep";
public const int ExitPass = 0;
public const int ExitFail = 1;
public const int ExitError = 2;
public const int ExitTimeout = 3;
private static readonly TimeSpan OverallTimeout = TimeSpan.FromMinutes(5);
private static readonly TimeSpan PreviewTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan CalibrationTimeout = TimeSpan.FromSeconds(120);
private static readonly TimeSpan SaveTimeout = TimeSpan.FromSeconds(60);
// カメラ SDK 由来のアセンブリ(SDK 未導入環境ではロードできなくても判定に含めない)
private static readonly string[] CameraSdkAssemblyPrefixes = { "TIS.", "lumenera." };
// 必ずロードできなければならない依存(NuGet 由来.依存更新で壊れやすいもの)
private static readonly string[] RequiredAssemblies = {
"OpenCvSharp",
"OpenCvSharp.Extensions",
"System.Memory",
"System.Buffers",
"System.Runtime.CompilerServices.Unsafe",
"System.Numerics.Vectors",
"System.Drawing.Common",
};
public static bool IsRequested(string[] args) {
return HasFlag(args, FlagSelfTest);
}
/// <summary>
/// セルフテストを実行して終了コードを返す(UI スレッド=STA のメインスレッドから呼ぶこと).
/// </summary>
public static int Run(string[] args) {
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var stamp = $"{DateTime.Now:yyyyMMdd-HHmmss}-{Process.GetCurrentProcess().Id}";
var tempRoot = Path.Combine(Path.GetTempPath(), "TIASshot-selftest");
var workDir = Path.Combine(tempRoot, $"work-{stamp}");
var outPath = GetArgValue(args, FlagOut) ?? Path.Combine(tempRoot, $"result-{stamp}.txt");
var keep = HasFlag(args, FlagKeep);
SelfTestReport report;
try {
// 不正な --selftest-out(空文字・不正文字等)も結果ファイルを作成できない場合と同じく異常(2)とする
outPath = Path.GetFullPath(outPath);
report = new SelfTestReport(outPath);
} catch (Exception ex) {
Debug.WriteLine($"セルフテスト結果ファイルを作成できません: {outPath}: {ex}");
return ExitError;
}
using (report)
using (var done = new ManualResetEventSlim(false)) {
StartWatchdog(report, done);
AppDomain.CurrentDomain.UnhandledException += (s, e) => {
report.WriteLine($"[FAIL] unhandled: 未処理例外でプロセスが終了します: {SelfTestReport.DescribeException(e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString()))}");
report.WriteLine("RESULT: FAIL (unhandled exception)");
Environment.Exit(ExitError);
};
var resolveLog = new AssemblyResolveLog();
resolveLog.Attach();
report.WriteLine("TIASshot selftest");
report.WriteLine($" exe: {Assembly.GetExecutingAssembly().Location}");
report.WriteLine($" version: {Assembly.GetExecutingAssembly().GetName().Version}");
report.WriteLine($" build: {BuildDescription()}");
report.WriteLine($" process: {(Environment.Is64BitProcess ? "x64" : "x86")} / CLR {Environment.Version}");
report.WriteLine($" started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
report.WriteLine($" work dir: {workDir}");
report.WriteLine($" result file: {outPath}");
report.WriteLine();
int exitCode;
try {
// WinForms の UI 例外をダイアログ表示ではなく例外として呼び出し元(DoEvents)へ伝播させる
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException);
var checks = new List<SelfTestCheck>();
var ctx = new Context { BaseDir = baseDir, WorkDir = workDir };
checks.Add(RunCheck(report, resolveLog, "deps", "依存アセンブリのロードと OpenCvSharp の最小実行",
c => CheckDependencies(c, ctx, resolveLog)));
string setupError = PrepareConfig(ctx);
checks.Add(RunCheck(report, resolveLog, "preview", "偽カメラ → ShowImage(FastClone)→ 表示用 Bitmap のプレビュー経路",
c => {
if (setupError != null) { c.Fail(setupError); return; }
CheckPreview(c);
}));
checks.Add(RunCheck(report, resolveLog, "calib-save", "合成チャートでチャート検出 → 色変換行列算出 → 撮影 → sRGB 変換画像の保存",
c => {
if (setupError != null) { c.Fail(setupError); return; }
CheckCalibrationAndSave(c, ctx);
}));
var failed = checks.Where(x => x.Status == SelfTestStatus.Fail).ToList();
report.WriteLine();
if (failed.Count == 0) {
report.WriteLine($"RESULT: PASS ({checks.Count}/{checks.Count})");
exitCode = ExitPass;
} else {
report.WriteLine($"RESULT: FAIL ({failed.Count}/{checks.Count} failed: {string.Join(", ", failed.Select(x => x.Id))})");
exitCode = ExitFail;
}
} catch (Exception ex) {
report.WriteLine($"[FAIL] runner: セルフテスト自体が異常終了しました: {SelfTestReport.DescribeException(ex)}");
report.WriteLine("RESULT: FAIL (runner error)");
exitCode = ExitError;
}
CleanupWorkDir(report, workDir, keep || exitCode != ExitPass);
done.Set();
return exitCode;
}
}
// ================================================================================
// 共通
// ================================================================================
private sealed class Context {
public string BaseDir;
public string WorkDir;
public string SaveRoot; // 一時設定の File/SaveFolder
public string UserSaveFolder; // 元の config.xml の File/SaveFolder(書き込まないことの確認用)
}
private static string BuildDescription() {
var flags = new List<string>();
#if DEBUG
flags.Add("DEBUG");
#endif
#if MEMMONITOR
flags.Add("MEMMONITOR");
#endif
#if WHITEBOARD
flags.Add("WHITEBOARD");
#endif
flags.Add("SELFTEST");
return string.Join(" ", flags);
}
private static bool HasFlag(string[] args, string name) {
return args.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// name の直後の引数を値として返す.name が無い・末尾にある・直後が別フラグ("--" 始まり)の場合は null(値の欠落扱い).
/// </summary>
private static string GetArgValue(string[] args, string name) {
for (var i = 0; i < args.Length - 1; i++) {
if (!string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) continue;
var value = args[i + 1];
return value.StartsWith("--", StringComparison.Ordinal) ? null : value;
}
return null;
}
private static void StartWatchdog(SelfTestReport report, ManualResetEventSlim done) {
var watchdog = new Thread(() => {
if (done.Wait(OverallTimeout)) return;
report.WriteLine($"[FAIL] watchdog: {OverallTimeout.TotalSeconds:0} 秒以内に終了しませんでした(ハングの疑い.Invoke の相互待機・モーダル表示等)");
report.WriteLine("RESULT: FAIL (timeout)");
Environment.Exit(ExitTimeout);
}) {
IsBackground = true,
Name = "SelfTest.Watchdog",
};
watchdog.Start();
}
/// <summary>
/// 1 項目を実行し,想定外の例外は FAIL として記録する.項目の実行中に解決できなかった
/// アプリ依存アセンブリ(AssemblyResolve)があれば,その項目の FAIL として記録する.
/// </summary>
private static SelfTestCheck RunCheck(SelfTestReport report, AssemblyResolveLog resolveLog,
string id, string title, Action<SelfTestCheck> body) {
var check = new SelfTestCheck(id, title);
var resolveStart = resolveLog.Count;
var sw = Stopwatch.StartNew();
try {
body(check);
} catch (Exception ex) {
check.FailException("想定外の例外", ex);
}
var ignorable = 0;
foreach (var entry in resolveLog.Since(resolveStart)) {
if (entry.IsIgnorable) {
ignorable++; // サテライトリソース等の探索(見つからないのが通常)
} else if (IsCameraSdkAssembly(entry.Name)) {
check.Warn($"AssemblyResolve(カメラ SDK・判定対象外): {entry}");
} else {
check.Fail($"アセンブリを解決できませんでした(AssemblyResolve): {entry}");
}
}
if (ignorable > 0) check.Info($"リソース系の AssemblyResolve {ignorable} 件(サテライトリソースの探索.判定対象外)");
check.Info($"所要時間 {sw.ElapsedMilliseconds} ms");
report.WriteCheck(check);
return check;
}
/// <summary>検査の 1 ステップを実行し,例外は FAIL として記録して次のステップへ進む</summary>
private static bool Step(SelfTestCheck c, string name, Action action) {
try {
action();
return true;
} catch (Exception ex) {
c.FailException(name, ex);
return false;
}
}
private static bool IsCameraSdkAssembly(string simpleName) {
if (simpleName == null) return false;
return CameraSdkAssemblyPrefixes.Any(p => simpleName.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// セルフテスト用の一時設定を作り,Config に読み込む.同梱 config.xml を複製し,
/// 保存先(File/SaveFolder)を作業フォルダに,参照 CSV を実行ファイル基準の絶対パスに,
/// 効果音を存在しないパス(再生しない)に差し替える.元の config.xml は変更しない.
/// </summary>
/// <returns>失敗時はその理由,成功時は null</returns>
private static string PrepareConfig(Context ctx) {
try {
// 一時設定の作成・読み込みは層②のテストハーネスと共通(SelfTestConfig)
var prepared = SelfTestConfig.Prepare(Path.Combine(ctx.BaseDir, "config.xml"), ctx.BaseDir, ctx.WorkDir);
ctx.UserSaveFolder = prepared.UserSaveFolder;
ctx.SaveRoot = prepared.SaveRoot;
if (!Config.IsLoaded()) return $"一時設定ファイルを読み込めません: {prepared.ConfigPath}";
return null;
} catch (Exception ex) {
return $"一時設定の準備に失敗しました: {SelfTestReport.DescribeException(ex)}";
}
}
private static void CleanupWorkDir(SelfTestReport report, string workDir, bool keep) {
if (!Directory.Exists(workDir)) return;
if (keep) {
report.WriteLine($"work dir kept: {workDir}");
return;
}
try {
Directory.Delete(workDir, true);
} catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) {
report.WriteLine($"WARN: 作業フォルダを削除できませんでした(手動で削除してください): {workDir}: {ex.Message}");
}
}
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('\\') + "\\";
return p.StartsWith(r, StringComparison.OrdinalIgnoreCase);
}
// ================================================================================
// 項目 1: 依存アセンブリ
// ================================================================================
private static void CheckDependencies(SelfTestCheck c, Context ctx, AssemblyResolveLog resolveLog) {
var loaded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
Step(c, "実行時設定ファイル(TIASshot.exe.config)", () => {
var cfg = AppDomain.CurrentDomain.SetupInformation.ConfigurationFile;
if (!File.Exists(cfg)) {
c.Fail($"実行時設定ファイルがありません(バインディングリダイレクトが効かない): {cfg}");
return;
}
CheckBindingRedirects(c, ctx.BaseDir, cfg, loaded);
});
Step(c, "参照アセンブリの推移的ロード", () => CheckReferenceClosure(c, ctx.BaseDir, loaded));
Step(c, "bin 内の管理アセンブリのロード", () => CheckBinAssemblies(c, ctx.BaseDir, loaded));
foreach (var name in RequiredAssemblies) {
c.Expect(loaded.Contains(name), $"必須の依存 {name} がロードされていません(bin に無い/参照が外れた可能性)");
}
Step(c, "OpenCvSharp の最小実行(ネイティブ DLL・Mat 演算・System.Memory 経由のアクセス)", () => CheckOpenCvRuntime(c));
Step(c, "BitmapConverter(Mat↔Bitmap)と FastClone", () => CheckBitmapConverter(c));
Step(c, "同梱の設定・参照ファイル", () => CheckAppFiles(c, ctx.BaseDir));
Step(c, "Form1 の実コンストラクタ(InitializeComponent)", () => CheckRealForm1Constructor(c));
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && IsUnder(a.Location, ctx.BaseDir))
.Select(a => $"{a.GetName().Name} {a.GetName().Version}")
.OrderBy(x => x);
c.Info($"bin からロード済み: {string.Join(", ", assemblies)}");
}
/// <summary>
/// バインディングリダイレクトの newVersion と bin の DLL のバージョンが一致し,
/// リダイレクト先の完全名でロードできることを確認する(依存更新でリダイレクトがずれると起動時に FileLoadException).
/// </summary>
private static void CheckBindingRedirects(SelfTestCheck c, string baseDir, string configFile, HashSet<string> loaded) {
var doc = new XmlDocument();
doc.Load(configFile);
var ns = new XmlNamespaceManager(doc.NameTable);
ns.AddNamespace("b", "urn:schemas-microsoft-com:asm.v1");
var count = 0;
foreach (XmlElement da in doc.SelectNodes("//b:dependentAssembly", ns)) {
var id = da.SelectSingleNode("b:assemblyIdentity", ns) as XmlElement;
var br = da.SelectSingleNode("b:bindingRedirect", ns) as XmlElement;
if (id == null || br == null) continue;
count++;
var name = id.GetAttribute("name");
var newVersion = br.GetAttribute("newVersion");
var token = id.GetAttribute("publicKeyToken");
var culture = string.IsNullOrEmpty(id.GetAttribute("culture")) ? "neutral" : id.GetAttribute("culture");
var dll = Path.Combine(baseDir, name + ".dll");
if (!File.Exists(dll)) {
// bin に無い=フレームワーク提供か未使用(例: System.ValueTuple は net471 以降 mscorlib に統合).
// 実際に参照されていれば推移的ロードで検査されるため,ここでは強制ロードしない
// (強制ロードすると不要な AssemblyResolve を自ら発生させる)
c.Info($"redirect {name} → {newVersion}: bin に DLL なし(フレームワーク提供または未使用.参照されていれば推移的ロードで検査)");
continue;
}
var binVersion = AssemblyName.GetAssemblyName(dll).Version;
if (binVersion != new Version(newVersion)) {
c.Fail($"リダイレクト不一致: {name} は newVersion={newVersion} だが bin の DLL は {binVersion}(App.config の bindingRedirect を更新する必要がある)");
continue;
}
try {
Assembly.Load(new AssemblyName($"{name}, Version={newVersion}, Culture={culture}, PublicKeyToken={token}"));
loaded.Add(name);
c.Info($"redirect {name} → {newVersion}: bin の DLL と一致・ロード OK");
} catch (Exception ex) {
c.FailException($"redirect {name} → {newVersion} のロード失敗", ex);
}
}
c.Info($"バインディングリダイレクト {count} 件を確認");
}
/// <summary>
/// 本体アセンブリの参照を推移的に辿って実際にロードする(bin 由来のアセンブリのみ再帰).
/// 実行時に初めて必要になる依存(例: OpenCvSharp → System.Memory → System.Runtime.CompilerServices.Unsafe)を起動前に解決させる.
/// </summary>
private static void CheckReferenceClosure(SelfTestCheck c, string baseDir, HashSet<string> loaded) {
var root = typeof(SelfTestRunner).Assembly;
var queue = new Queue<KeyValuePair<AssemblyName, string>>();
foreach (var r in root.GetReferencedAssemblies()) queue.Enqueue(new KeyValuePair<AssemblyName, string>(r, root.GetName().Name));
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var framework = 0;
while (queue.Count > 0) {
var item = queue.Dequeue();
var an = item.Key;
if (!seen.Add(an.Name)) continue;
Assembly asm;
try {
asm = Assembly.Load(an);
} catch (Exception ex) {
var msg = $"{an.FullName}(参照元 {item.Value})のロード失敗";
if (IsCameraSdkAssembly(an.Name)) {
c.Warn($"{msg}(カメラ SDK・判定対象外): {SelfTestReport.DescribeException(ex)}");
} else {
c.FailException(msg, ex);
}
continue;
}
loaded.Add(an.Name);
if (!IsUnder(asm.Location, baseDir)) {
framework++;
continue;
}
var actual = asm.GetName().Version;
c.Info($"{an.Name} {actual}(参照 {an.Version},参照元 {item.Value}): bin からロード");
if (IsCameraSdkAssembly(an.Name)) continue; // SDK の内部依存までは辿らない
foreach (var r in asm.GetReferencedAssemblies()) {
queue.Enqueue(new KeyValuePair<AssemblyName, string>(r, an.Name));
}
}
c.Info($"参照を辿ってロードしたアセンブリ {seen.Count} 件(うち GAC/Framework {framework} 件)");
}
/// <summary>bin 直下の管理アセンブリ(DLL)がすべてロードできることを確認する(ネイティブ DLL は対象外)</summary>
private static void CheckBinAssemblies(SelfTestCheck c, string baseDir, HashSet<string> loaded) {
var managed = 0;
foreach (var dll in Directory.GetFiles(baseDir, "*.dll")) {
AssemblyName an;
try {
an = AssemblyName.GetAssemblyName(dll);
} catch (BadImageFormatException) {
continue; // ネイティブ DLL(管理アセンブリでない)
}
managed++;
if (loaded.Contains(an.Name)) continue;
try {
Assembly.Load(an);
loaded.Add(an.Name);
c.Info($"{an.Name} {an.Version}: bin からロード(本体から直接は参照されない)");
} catch (Exception ex) {
if (IsCameraSdkAssembly(an.Name)) {
c.Warn($"{an.FullName} のロード失敗(カメラ SDK・判定対象外): {SelfTestReport.DescribeException(ex)}");
} else {
c.FailException($"bin の {Path.GetFileName(dll)}({an.FullName})のロード失敗", ex);
}
}
}
c.Info($"bin 直下の管理アセンブリ {managed} 件を確認");
}
private static void CheckOpenCvRuntime(SelfTestCheck c) {
c.Info($"OpenCV {Cv2.GetVersionString()}");
using (var src = new Mat(48, 64, MatType.CV_8UC3, new Scalar(10, 20, 30)))
using (var blurred = new Mat()) {
Cv2.GaussianBlur(src, blurred, new OpenCvSharp.Size(5, 5), 0);
var mean = Cv2.Mean(blurred);
c.Expect(Math.Abs(mean.Val0 - 10) < 0.5 && Math.Abs(mean.Val1 - 20) < 0.5 && Math.Abs(mean.Val2 - 30) < 0.5,
$"GaussianBlur/Mean の結果が不正: {mean}");
// System.Memory(Span)経由の画素アクセス
var span = src.AsSpan<byte>();
long sum = 0;
for (var i = 0; i < span.Length; i++) sum += span[i];
c.Expect(sum == 48L * 64 * (10 + 20 + 30), $"Mat.AsSpan の画素合計が不正: {sum}");
}
// 行列演算(CV_64F): A * A^-1 ≈ I
using (var a = new Mat(3, 3, MatType.CV_64FC1)) {
var vals = new double[] { 4, 1, 2, 0, 3, 1, 1, 0, 2 };
for (var i = 0; i < 9; i++) a.At<double>(i / 3, i % 3) = vals[i];
// MatExpr もネイティブ資源を持つため,ToMat の元の式も using で解放する
using (var invExpr = a.Inv())
using (var inv = invExpr.ToMat())
using (var prodExpr = a * inv)
using (var prod = prodExpr.ToMat())
using (var eyeExpr = Mat.Eye(3, 3, MatType.CV_64FC1))
using (var eye = eyeExpr.ToMat()) {
var err = Cv2.Norm(prod, eye, NormTypes.L2);
c.Expect(err < 1e-9, $"行列演算(A*A^-1)の誤差が大きい: {err}");
}
}
using (var dict = CvAruco.GetPredefinedDictionary(PredefinedDictionaryType.Dict4X4_50)) {
c.Expect(dict.MarkerSize == 4, $"ArUco 辞書のマーカーサイズが不正: {dict.MarkerSize}");
}
c.Info("Mat 演算・行列演算・Span アクセス・ArUco 辞書: OK");
}
private static void CheckBitmapConverter(SelfTestCheck c) {
using (var src = SelfTestChart.CreatePlainFrame(3, 64, 48)) {
Cv2.Circle(src, new OpenCvSharp.Point(20, 20), 8, new Scalar(0, 255, 0), -1);
using (var bmp = src.ToBitmap())
using (var clone = BitmapUtil.FastClone(bmp))
using (var back = clone.ToMat()) {
c.Expect(bmp.Width == 64 && bmp.Height == 48, $"ToBitmap のサイズが不正: {bmp.Width}x{bmp.Height}");
c.Expect(!ReferenceEquals(bmp, clone), "FastClone が同一インスタンスを返した");
var diff = Cv2.Norm(src, back, NormTypes.L1);
c.Expect(diff == 0, $"Mat → Bitmap → FastClone → Mat の往復で画素が変化: L1={diff}");
}
}
c.Info("Mat → Bitmap → FastClone → Mat の往復: 画素一致");
}
private static void CheckAppFiles(SelfTestCheck c, string baseDir) {
var configPath = Path.Combine(baseDir, "config.xml");
if (!c.Expect(File.Exists(configPath), $"config.xml がありません: {configPath}")) return;
Config.Load(configPath);
c.Expect(Config.IsLoaded(), "config.xml を読み込めません(ルート要素 Config が無い)");
foreach (var key in new[] { "File/TccSrgbRef", "File/TccXyzRef" }) {
var path = Path.Combine(baseDir, Config.GetString(key));
using (var m = IoUtil.LoadMatFromCsv(path)) {
c.Expect(m != null && m.Rows == 24 && m.Cols == 3,
$"{key}({path})を 24x3 として読み込めません");
}
}
c.Info("config.xml・tcc_srgb.csv・tcc_xyz.csv: OK");
}
/// <summary>
/// 通常起動と同じ Form1 コンストラクタ(InitializeComponent.IC Imaging Control のコントロール生成を含む)を実行する.
/// Form1_Load(カメラ接続)は表示しないため発火しない.カメラ SDK 起因の失敗は判定対象外(WARN)とする.
/// </summary>
private static void CheckRealForm1Constructor(SelfTestCheck c) {
try {
using (var form = new Form1()) {
c.Info($"Form1 コンストラクタ: OK({form.Text})");
}
} catch (Exception ex) {
if (IsCameraSdkRelated(ex)) {
c.Warn($"Form1 コンストラクタがカメラ SDK 起因で失敗(SDK 未導入環境では想定内): {SelfTestReport.DescribeException(ex)}");
} else {
c.FailException("Form1 コンストラクタの失敗", ex);
}
}
}
private static bool IsCameraSdkRelated(Exception ex) {
for (var e = ex; e != null; e = e.InnerException) {
var file = (e as FileNotFoundException)?.FileName ?? (e as FileLoadException)?.FileName;
if (file != null && IsCameraSdkAssembly(new AssemblyName(file).Name)) return true;
if ((e.StackTrace ?? "").Contains("TIS.Imaging")) return true;
}
return false;
}
// ================================================================================
// 項目 2: プレビュー経路
// ================================================================================
private static void CheckPreview(SelfTestCheck c) {
const int frames = 12;
const int width = 640;
const int height = 480;
Form1 form = null;
PreviewMonitor monitor = null;
SelfTestCamera camera = null;
try {
form = new Form1(Form1.SelfTestHeadless.Instance);
monitor = new PreviewMonitor(); // 実コンストラクタ(InitializeComponent を含む)
monitor.SelfTestForceShown(new System.Drawing.Size(1280, 800)); // 副ディスプレイ有りを模す
form.SelfTestStart(monitor);
camera = new SelfTestCamera(form, i => SelfTestChart.CreatePlainFrame(i, width, height));
if (!c.Expect(camera.Connect(), $"偽カメラの接続(CameraBase.BootCheck)に失敗: {camera.ErrorMsg}",
"偽カメラ接続(CameraBase コンストラクタ・BootCheck): OK")) return;
camera.StartPreview(frames, 30, false);
var finished = SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, PreviewTimeout);
if (!c.Expect(finished, $"プレビューが {PreviewTimeout.TotalSeconds:0} 秒以内に終わりません({camera.FramesDelivered}/{frames} フレーム)")) return;
if (camera.PreviewError != null) c.FailException("プレビューコールバックの例外", camera.PreviewError);
c.Expect(camera.FramesDelivered == frames, $"コールバックへ渡したフレーム数が不正: {camera.FramesDelivered}/{frames}",
$"コールバック → ProcessCalibrationFrame → Form1.ShowImage: {frames} フレーム");
// 本モニタはタイマー(約 33ms)駆動.最終フレームが反映されるまでメッセージを回す
SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(300));
monitor.SelfTestRefresh();
// 入力 Bitmap(カメラ側の二重バッファ)をすべて解放してから表示側を検査する.
// 表示側が入力と独立したコピー(FastClone)を持っていれば,解放後も読める.
camera.Disconnect();
var expected = SelfTestChart.PlainFrameColor(frames - 1);
CheckDisplayedImage(c, "本モニタ picDisplay(MainRefreshTimer_Tick)", form.SelfTestDisplayedImage, camera, width, height, expected);
CheckDisplayedImage(c, "副モニタ PreviewMonitor(UpdateImage → RefreshTimer_Tick)", monitor.SelfTestDisplayedImage, camera, width, height, expected);
c.Expect(form.SelfTestLastError == null, $"エラー通知(ShowError)が発生: {form.SelfTestLastError}");
} finally {
ShutdownCamera(camera);
form?.SelfTestShutdown();
form?.Dispose();
monitor?.SelfTestShutdown();
monitor?.Dispose();
}
}
private static void CheckDisplayedImage(SelfTestCheck c, string label, Image image, SelfTestCamera camera,
int width, int height, Scalar expectedBgr) {
if (!c.Expect(image != null, $"{label}: 表示用 Bitmap が設定されていません")) return;
c.Expect(!camera.IsProducedBitmap(image), $"{label}: 表示用 Bitmap がカメラの入力 Bitmap そのもの(UI 所有コピーになっていない)");
var bmp = image as Bitmap;
if (!c.Expect(bmp != null, $"{label}: 表示画像が Bitmap ではありません({image.GetType().Name})")) return;
try {
c.Expect(bmp.Width == width && bmp.Height == height, $"{label}: サイズが不正 {bmp.Width}x{bmp.Height}(期待 {width}x{height})");
var px = bmp.GetPixel(width / 2, height / 2);
var ok = px.B == (int)expectedBgr.Val0 && px.G == (int)expectedBgr.Val1 && px.R == (int)expectedBgr.Val2;
c.Expect(ok, $"{label}: 画素値が最終フレームと一致しない(R{px.R} G{px.G} B{px.B},期待 R{expectedBgr.Val2} G{expectedBgr.Val1} B{expectedBgr.Val0})",
$"{label}: 最終フレームの UI 所有コピーを表示(入力 Bitmap 解放後も有効)");
} catch (ArgumentException ex) {
// 解放済み Bitmap へのアクセスは ArgumentException(パラメーターが無効)になる
c.FailException($"{label}: 表示用 Bitmap が入力の解放とともに無効になった(所有権モデル違反の疑い)", ex);
}
}
private static void ShutdownCamera(SelfTestCamera camera) {
if (camera == null) return;
camera.RequestStopPreview();
// プレビュースレッドは Invoke で UI スレッドを待つことがあるため,Join ではなくメッセージを回して待つ
SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, TimeSpan.FromSeconds(10));
SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, TimeSpan.FromSeconds(10));
camera.Disconnect();
}
// ================================================================================
// 項目 3: 校正〜保存
// ================================================================================
private static void CheckCalibrationAndSave(SelfTestCheck c, Context ctx) {
c.Info($"保存先を一時フォルダに差し替え: {ctx.SaveRoot}(config.xml の {ctx.UserSaveFolder} には書き込まない)");
Mat tccSrgb = null;
Mat chart = null;
Form1 form = null;
SelfTestCamera camera = null;
try {
tccSrgb = IoUtil.LoadMatFromCsv(Config.GetString("File/TccSrgbRef"));
if (!c.Expect(tccSrgb != null, "tcc_srgb.csv を読み込めません")) return;
chart = SelfTestChart.CreateChartFrame(tccSrgb);
var chartForFactory = chart;
form = new Form1(Form1.SelfTestHeadless.Instance);
form.SelfTestStart(null);
camera = new SelfTestCamera(form, i => chartForFactory.Clone());
if (!c.Expect(camera.Connect(), $"偽カメラの接続に失敗: {camera.ErrorMsg}")) return;
// --- 自動校正(チャート検出 → 固定判定 → ホワイトバランス → 変換行列算出) ---
var maxFrames = Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + 60;
camera.StartPreview(maxFrames, 0, true);
var finished = SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, CalibrationTimeout);
if (!c.Expect(finished, $"校正が {CalibrationTimeout.TotalSeconds:0} 秒以内に終わりません({camera.FramesDelivered} フレーム)")) return;
if (camera.PreviewError != null) {
c.FailException("プレビュー(校正)中の例外", camera.PreviewError);
return;
}
if (!c.Expect(camera.IsCalibrated,
$"校正が完了しません({camera.FramesDelivered} フレーム.最後の表示: 「{form.SelfTestMessage}」.チャート検出の失敗が疑われる)")) return;
SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(100)); // CalcTcc の ShowMessage / EnableShots(Invoke)を処理させる
c.Info($"チャート検出・校正完了: {camera.FramesDelivered} フレーム,疑似ゲイン {camera.DescribeGain()},表示「{form.SelfTestMessage}」");
c.Expect(form.SelfTestShotsEnabled, "校正完了後に撮影ボタンが有効化されていません(CalcTcc → EnableShots)");
var white = camera.MeasureWhitePatch();
var refB = Config.GetFloat("Calib/Reference/B");
var refG = Config.GetFloat("Calib/Reference/G");
var refR = Config.GetFloat("Calib/Reference/R");
c.Expect(Math.Abs(white.Val0 - refB) < 10 && Math.Abs(white.Val1 - refG) < 10 && Math.Abs(white.Val2 - refR) < 10,
$"白色票がホワイトバランス目標に収束していません: B{white.Val0:0.0} G{white.Val1:0.0} R{white.Val2:0.0}(目標 {refB}/{refG}/{refR})",
$"白色票(13 番)の計測値: B{white.Val0:0.0} G{white.Val1:0.0} R{white.Val2:0.0}(目標 {refB}/{refG}/{refR})");
var channels = GetConversionChannels();
var frameSize = new OpenCvSharp.Size(SelfTestChart.FrameWidth, SelfTestChart.FrameHeight);
CheckCalibrationOutputs(c, ctx, camera.CalibrationFolder, channels, frameSize, tccSrgb);
// --- 撮影 → 保存(ShotOne → RunShotLoop → SaveThread → SaveImages) ---
var shotOk = Step(c, "撮影(CameraBase.ShotOne)", () => camera.ShotOne());
var saved = SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, SaveTimeout);
if (!c.Expect(saved, $"画像保存が {SaveTimeout.TotalSeconds:0} 秒以内に終わりません")) return;
SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(100)); // SaveThread の ShowMessage / EnableShots(Invoke)を処理させる
if (!shotOk) return;
c.Expect(form.SelfTestLastError == null, $"エラー通知(ShowError)が発生: {form.SelfTestLastError}");
c.Expect(form.SelfTestShotsEnabled, "保存完了後に撮影ボタンが再有効化されていません(SaveThread → EnableShots(true))");
c.Info($"撮影・保存完了: 表示「{form.SelfTestMessage}」");
CheckShotOutputs(c, ctx, camera.CurrentSaveFolder, camera.CalibrationFolder, channels, frameSize, tccSrgb);
} finally {
ShutdownCamera(camera);
form?.SelfTestShutdown();
form?.Dispose();
chart?.Dispose();
tccSrgb?.Dispose();
}
}
/// <summary>ColorCorrector と同じ規則で変換行列のチャネル数一覧を得る(4〜17 の範囲・重複除去・昇順)</summary>
private static int[] GetConversionChannels() {
return Config.GetString("Calib/ConversionChannels")
.Split(',')
.Select(x => int.Parse(x.Trim()))
.Where(x => x >= 4 && x <= 17)
.Distinct()
.OrderBy(x => x)
.ToArray();
}
private static string ChannelFile(string key, int channel, int no = 1) {
return Config.GetString(key).Replace("{CN}", $"{channel:00}").Replace("{NO}", $"{no:0000}");
}
private static void CheckCalibrationOutputs(SelfTestCheck c, Context ctx, string folder, int[] channels,
OpenCvSharp.Size frameSize, Mat tccSrgb) {
if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"校正データのフォルダがありません: {folder}")) return;
c.Expect(IsUnder(folder, ctx.SaveRoot), $"校正データが一時フォルダの外に保存されました: {folder}");
CheckImageFile(c, Path.Combine(folder, Config.GetString("File/TccImage")), frameSize);
CheckImageFile(c, Path.Combine(folder, Config.GetString("File/TccRoisImage")), frameSize);
CheckCsvMatrix(c, Path.Combine(folder, Config.GetString("File/TccRgbValues")), 24, 3);
foreach (var ch in channels) {
CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2Srgb", ch)), ch, 3);
CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2Xyz", ch)), ch, 3);
CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvSrgb2Xyz", ch)), ch, 3);
if (ch == 17) CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2XyzOld", ch)), ch, 3);
// 校正チャートを変換した色票値が参照値に戻っているか(合成チャートの歪みは 1 次式なので戻るはず)
var convertedFile = ChannelFile("File/TccSrgbValues", ch);
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 = PatchColorError.Compare(PatchColorError.ToArray(converted), PatchColorError.ToArray(tccSrgb)).MaxAbs;
c.Expect(maxErr < 5.0, $"{ch} 次元: 色票の変換値が参照値から離れすぎ(最大誤差 {maxErr:0.00})",
$"{ch} 次元: 変換行列(有限値)と色票の変換値(参照値との最大誤差 {maxErr:0.00})");
}
}
}
private static void CheckShotOutputs(SelfTestCheck c, Context ctx, string folder, string calibFolder, int[] channels,
OpenCvSharp.Size frameSize, Mat tccSrgb) {
if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"撮影データのフォルダがありません: {folder}")) return;
c.Expect(IsUnder(folder, ctx.SaveRoot), $"撮影データが一時フォルダの外に保存されました: {folder}");
c.Expect(!string.Equals(folder, calibFolder, StringComparison.OrdinalIgnoreCase), "撮影データが校正データと同じフォルダに保存されました");
var info = Path.Combine(folder, Config.GetString("File/Info"));
if (c.Expect(File.Exists(info), $"撮影情報がありません: {info}")) {
var text = File.ReadAllText(info);
c.Expect(text.Contains("Shot,Time(ms),Note"), $"撮影情報の書式が不正: {info}");
}
CheckImageFile(c, Path.Combine(folder, ChannelFile("File/RgbImage", 0)), frameSize);
foreach (var ch in channels) {
var path = Path.Combine(folder, ChannelFile("File/SrgbImage", ch));
if (!CheckImageFile(c, path, frameSize)) continue;
// 変換画像上の色票が参照値(tcc_srgb)に戻っているか(JPEG 圧縮の誤差を許容)
using (var img = Cv2.ImRead(path, ImreadModes.Color)) {
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++) measured[i, j] = mean[j];
}
}
// 誤差の計算は実機テスト(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}");
}
}
}
private static bool CheckImageFile(SelfTestCheck c, string path, OpenCvSharp.Size expectedSize) {
if (!c.Expect(File.Exists(path), $"画像がありません: {path}")) return false;
using (var img = Cv2.ImRead(path, ImreadModes.Color)) {
if (!c.Expect(!img.Empty(), $"画像を読み戻せません: {path}")) return false;
return c.Expect(img.Size() == expectedSize, $"画像サイズが不正: {path}({img.Width}x{img.Height},期待 {expectedSize.Width}x{expectedSize.Height})");
}
}
private static void CheckCsvMatrix(SelfTestCheck c, string path, int rows, int cols) {
if (!c.Expect(File.Exists(path), $"CSV がありません: {path}")) return;
using (var m = IoUtil.LoadMatFromCsv(path)) {
if (!c.Expect(m != null, $"CSV を読み戻せません: {path}")) return;
if (!c.Expect(m.Rows == rows && m.Cols == cols, $"CSV の形状が不正: {path}({m.Rows}x{m.Cols},期待 {rows}x{cols})")) return;
var allFinite = true;
var allZero = true;
for (var i = 0; i < m.Rows; i++) {
for (var j = 0; j < m.Cols; j++) {
var v = m.At<double>(i, j);
if (double.IsNaN(v) || double.IsInfinity(v)) allFinite = false;
if (v != 0) allZero = false;
}
}
c.Expect(allFinite, $"CSV に非有限値(NaN/Infinity)が含まれます: {path}");
c.Expect(!allZero, $"CSV が全て 0 です: {path}");
}
}
// ================================================================================
// AssemblyResolve の記録
// ================================================================================
/// <summary>
/// AppDomain.AssemblyResolve(通常のバインドで見つからなかったアセンブリ)を記録する.
/// 解決はせず null を返す(挙動は変えない).
/// </summary>
private sealed class AssemblyResolveLog {
internal sealed class Entry {
public string Name;
public string FullName;
public string Requester;
public bool IsIgnorable => Name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase)
|| Name.EndsWith(".XmlSerializers", StringComparison.OrdinalIgnoreCase);
public override string ToString() => $"{FullName}(要求元 {Requester ?? "不明"})";
}
private readonly List<Entry> _entries = new List<Entry>();
private readonly object _lock = new object();
public int Count {
get { lock (_lock) return _entries.Count; }
}
public void Attach() {
AppDomain.CurrentDomain.AssemblyResolve += (s, e) => {
var name = new AssemblyName(e.Name).Name;
lock (_lock) {
_entries.Add(new Entry { Name = name, FullName = e.Name, Requester = e.RequestingAssembly?.GetName().Name });
}
return null;
};
}
public List<Entry> Since(int start) {
lock (_lock) return _entries.Skip(start).ToList();
}
}
}
}
#endif