diff --git a/CLAUDE.md b/CLAUDE.md index 54ecc30..b5c1e46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: 3 バージョン(v1.6/v1.7/現行)を実機 MEMMONITOR で性能評価計測し,2 段階改善(保存経路 v1.6→v1.7・プレビュー経路 v1.7→現行)を定量化(PLAN_04/TEST_02・PR #18).あわせて完了済み PLAN_01/02 と対比用 LEAKY_REPRO フラグを撤去(挙動不変) +最新: `/auto-audit` でコードベース全体をバグ・脆弱性観点で巡回監査し,再現テストで証明できた 7 件を自動修正(保存パス無害化・設定値パース・資源解放・ロケール依存・ゲインのクランプ等).続けて重大度「高」の「撮影失敗が無通知でデータ破損する」問題を即中止+ダイアログ通知に修正.未修正の指摘は `.claude/auto-audit-report.md` に集約 ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/TIASshot.Tests/ColorCorrectorTests.cs b/TIASshot.Tests/ColorCorrectorTests.cs index f06f12e..b06447b 100644 --- a/TIASshot.Tests/ColorCorrectorTests.cs +++ b/TIASshot.Tests/ColorCorrectorTests.cs @@ -474,6 +474,118 @@ } } + // --------------------------------------------------------------- + // CalcConvertMatrix / ConvertColor の中間 Mat 解放テスト + // --------------------------------------------------------------- + + /// + /// ExtendMat の戻り値(中間 Mat)を捕捉するテスト用サブクラス. + /// 挙動は変えず,戻り値の参照だけを記録する. + /// + private class ExtendMatCapturingCorrector : ColorCorrector { + public readonly List CapturedExtended = new List(); + + public ExtendMatCapturingCorrector() : base(null, null, new int[] { 4 }) { } + + internal override Mat ExtendMat(Mat src, int channels) { + var result = base.ExtendMat(src, channels); + CapturedExtended.Add(result); + return result; + } + } + + /// + /// CalcConvertMatrix: 通常入力(Nx3)では ExtendMat が新規確保した中間 Mat を + /// 呼び出し完了時に解放し,入力 src は解放しないこと(Mat リーク防止) + /// + [Test] + public void CalcConvertMatrix_通常入力_中間拡張Matを解放し入力は保持する() { + // Arrange + using (var src = CreateLinearColorChart(rows: 24)) + using (var target = CreateScaledColorChart(rows: 24, scale: 1.1)) { + var corrector = new ExtendMatCapturingCorrector(); + + // Act + using (var conv = corrector.CalcConvertMatrix(src, target, channels: 4)) { + // Assert + Assert.AreEqual(1, corrector.CapturedExtended.Count, "ExtendMat は 1 回呼ばれる"); + var extended = corrector.CapturedExtended[0]; + Assert.AreNotSame(src, extended, "通常入力では新規 Mat が確保される(前提確認)"); + Assert.IsTrue(extended.IsDisposed, "中間拡張 Mat は CalcConvertMatrix 完了時に解放される"); + Assert.IsFalse(src.IsDisposed, "入力 src は解放されない(所有権は呼び出し側)"); + } + } + } + + /// + /// ConvertColor: 通常入力(Nx3)では ExtendMat が新規確保した中間 Mat を + /// 呼び出し完了時に解放し,入力 src は解放しないこと(Mat リーク防止) + /// + [Test] + public void ConvertColor_通常入力_中間拡張Matを解放し入力は保持する() { + // Arrange + using (var src = new Mat(1, 3, MatType.CV_64FC1)) + using (var conv = CreateIdentityLikeConvMat()) { + SetBGR(src, row: 0, b: 10.0, g: 20.0, r: 30.0); + var corrector = new ExtendMatCapturingCorrector(); + + // Act + using (var dst = corrector.ConvertColor(src, conv)) { + // Assert + Assert.AreEqual(1, corrector.CapturedExtended.Count, "ExtendMat は 1 回呼ばれる"); + var extended = corrector.CapturedExtended[0]; + Assert.AreNotSame(src, extended, "通常入力では新規 Mat が確保される(前提確認)"); + Assert.IsTrue(extended.IsDisposed, "中間拡張 Mat は ConvertColor 完了時に解放される"); + Assert.IsFalse(src.IsDisposed, "入力 src は解放されない(所有権は呼び出し側)"); + } + } + } + + /// + /// CalcConvertMatrix: ExtendMat のパススルー入力(3 列相当でない Mat は src をそのまま返す)でも + /// 入力 src を解放しないこと(無条件 Dispose による入力破壊の回帰防止) + /// + [Test] + public void CalcConvertMatrix_パススルー入力_入力Matを解放しない() { + // Arrange: 24x4 → Cols*Channels() = 4 != 3 なので ExtendMat は src をそのまま返す + using (var src = new Mat(24, 4, MatType.CV_64FC1, new Scalar(1.0))) + using (var target = CreateScaledColorChart(rows: 24, scale: 1.1)) { + for (int row = 0; row < 24; row++) { + src.At(row, 1) = row + 1.0; + src.At(row, 2) = (row + 1) * 2.0; + src.At(row, 3) = (row + 1) * 3.0; + } + var corrector = new ExtendMatCapturingCorrector(); + + // Act + using (var conv = corrector.CalcConvertMatrix(src, target, channels: 4)) { + // Assert + Assert.AreSame(src, corrector.CapturedExtended[0], "パススルー入力では src がそのまま返る(前提確認)"); + Assert.IsFalse(src.IsDisposed, "パススルー入力の src は解放されない(所有権は呼び出し側)"); + } + } + } + + /// + /// ConvertColor: ExtendMat のパススルー入力(3 列相当でない Mat は src をそのまま返す)でも + /// 入力 src を解放しないこと(無条件 Dispose による入力破壊の回帰防止) + /// + [Test] + public void ConvertColor_パススルー入力_入力Matを解放しない() { + // Arrange: 1x4 → Cols*Channels() = 4 != 3 なので ExtendMat は src をそのまま返す + using (var src = new Mat(1, 4, MatType.CV_64FC1, new Scalar(1.0))) + using (var conv = CreateIdentityLikeConvMat()) { + var corrector = new ExtendMatCapturingCorrector(); + + // Act + using (var dst = corrector.ConvertColor(src, conv)) { + // Assert + Assert.AreSame(src, corrector.CapturedExtended[0], "パススルー入力では src がそのまま返る(前提確認)"); + Assert.IsFalse(src.IsDisposed, "パススルー入力の src は解放されない(所有権は呼び出し側)"); + } + } + } + /// /// CalcTcc テスト用に,参照される File/* キーを持つ最小 Config を読み込む. /// ファイル名は種類ごとに識別可能な文字列にし,コールバックの path で判別する. diff --git a/TIASshot.Tests/ConfigTests.cs b/TIASshot.Tests/ConfigTests.cs index 538f91c..6d6b36b 100644 --- a/TIASshot.Tests/ConfigTests.cs +++ b/TIASshot.Tests/ConfigTests.cs @@ -24,6 +24,8 @@ " 42" + " hello" + " deep" + + " abc" + + " " + ""; [SetUp] @@ -103,5 +105,81 @@ var value = Config.GetString("Nested/Child"); Assert.AreEqual(ExpectedNested, value); } + + /// + /// 数値としてパースできない値を GetInt で指定したとき ArgumentException がスローされる + /// (キー欠落時と同様に設定不備として扱う.黙って 0 を返さない) + /// + [Test] + public void GetInt_数値でない値_ArgumentExceptionをスロー() { + Assert.Throws(() => Config.GetInt("BadNumVal")); + } + + /// + /// 数値としてパースできない値を GetFloat で指定したとき ArgumentException がスローされる + /// + [Test] + public void GetFloat_数値でない値_ArgumentExceptionをスロー() { + Assert.Throws(() => Config.GetFloat("BadNumVal")); + } + + /// + /// 空要素を GetInt で指定したとき ArgumentException がスローされる + /// + [Test] + public void GetInt_空の値_ArgumentExceptionをスロー() { + Assert.Throws(() => Config.GetInt("EmptyVal")); + } + + /// + /// 小数点にカンマを使うロケール(de-DE)でもピリオド小数の設定値を正しく解釈する + /// (config.xml はピリオド小数で記述される仕様のため,パースはロケール非依存であること) + /// + [Test] + public void GetFloat_カンマ小数ロケール_ピリオド小数を正しく解釈する() { + var original = System.Threading.Thread.CurrentThread.CurrentCulture; + try { + System.Threading.Thread.CurrentThread.CurrentCulture = + new System.Globalization.CultureInfo("de-DE"); + var value = Config.GetFloat("FloatVal"); + Assert.AreEqual(ExpectedFloat, value, 1e-6f); + } finally { + System.Threading.Thread.CurrentThread.CurrentCulture = original; + } + } + + /// + /// 特性化テスト: リポジトリ同梱の config.xml に対して,本体コードが参照する + /// 全数値キーが例外なく読めること(不正値 throw 化で正常系が変わらない保護) + /// + [Test] + public void GetIntGetFloat_同梱config_本体が参照する全数値キーを例外なく読める() { + var repoConfigPath = Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", "..", "..", "..", "TIASshot", "config.xml")); + Assert.IsTrue(File.Exists(repoConfigPath), $"同梱 config.xml が見つからない: {repoConfigPath}"); + Config.Load(repoConfigPath); + + string[] intKeys = { + "Calib/ChartSetCount", "Calib/Frames", "Calib/UpdateInterval", + "Shot/PreviewMonitorWidth", "LightSource/BaudRate", + "DFK33UX178/ROI/X", "DFK33UX178/ROI/Y", "DFK33UX178/ROI/W", "DFK33UX178/ROI/H", + "DFK23UX249/ROI/X", "DFK23UX249/ROI/Y", "DFK23UX249/ROI/W", "DFK23UX249/ROI/H", + }; + string[] floatKeys = { + "Lucam/Exposure", "Lucam/Gain", "Lucam/GainB", "Lucam/GainG", "Lucam/GainR", + "Calib/ChartSetCriteria", "Calib/UpdateRate", + "Calib/Reference/B", "Calib/Reference/G", "Calib/Reference/R", + }; + foreach (var key in intKeys) { + Assert.DoesNotThrow(() => Config.GetInt(key), $"GetInt(\"{key}\") が例外を投げた"); + } + foreach (var key in floatKeys) { + Assert.DoesNotThrow(() => Config.GetFloat(key), $"GetFloat(\"{key}\") が例外を投げた"); + } + // ゼロ除算・校正停止に直結する値が有効値で読めることを固定する + Assert.AreNotEqual(0, Config.GetInt("Calib/UpdateInterval")); + Assert.AreNotEqual(0.0f, Config.GetFloat("Calib/UpdateRate")); + } } } diff --git a/TIASshot.Tests/GainCalcCalcGainValueTests.cs b/TIASshot.Tests/GainCalcCalcGainValueTests.cs new file mode 100644 index 0000000..56e92d3 --- /dev/null +++ b/TIASshot.Tests/GainCalcCalcGainValueTests.cs @@ -0,0 +1,134 @@ +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// GainCalc.CalcGainValue のテスト. + /// カメラのゲインプロパティ(VCDRangeProperty)は範囲外の値を代入すると + /// 例外を投げるため,設定値は必ず [rangeMin, rangeMax] に収まる必要がある. + /// + [TestFixture] + public class GainCalcCalcGainValueTests { + + // --------------------------------------------------------------- + // 正常ケース(特性化: 従来の四捨五入挙動を保つこと) + // --------------------------------------------------------------- + + /// + /// CalcGainValue_比率が範囲内_従来どおり四捨五入した値を返す: + /// 100 * 1.5 = 150.0 → (int)(0.5 + 150.0) = 150 + /// + [Test] + public void CalcGainValue_比率が範囲内_従来どおり四捨五入した値を返す() { + int result = GainCalc.CalcGainValue(100, 1.5f, 0, 255); + Assert.AreEqual(150, result); + } + + /// + /// CalcGainValue_小数部が切り上がる場合_四捨五入した値を返す: + /// 100 * 1.006 = 100.6 → (int)(0.5 + 100.6) = 101 + /// + [Test] + public void CalcGainValue_小数部が切り上がる場合_四捨五入した値を返す() { + int result = GainCalc.CalcGainValue(100, 1.006f, 0, 255); + Assert.AreEqual(101, result); + } + + /// + /// CalcGainValue_比率が1_現在値をそのまま返す: + /// 更新が不要なとき値が変化しないこと + /// + [Test] + public void CalcGainValue_比率が1_現在値をそのまま返す() { + int result = GainCalc.CalcGainValue(133, 1.0f, 0, 255); + Assert.AreEqual(133, result); + } + + // --------------------------------------------------------------- + // 範囲クランプ(再現テスト) + // --------------------------------------------------------------- + + /// + /// CalcGainValue_結果がrangeMaxを超える_rangeMaxを返す: + /// 暗いフレームで比率が大きくなると 133 * 2.008 = 267 となり + /// 設定可能な最大値 255 を超える.範囲上限に飽和させること + /// + [Test] + public void CalcGainValue_結果がrangeMaxを超える_rangeMaxを返す() { + int result = GainCalc.CalcGainValue(133, 2.008f, 0, 255); + Assert.AreEqual(255, result); + } + + /// + /// CalcGainValue_結果がrangeMin未満_rangeMinを返す: + /// 比率が極端に小さいとき範囲下限に飽和させること + /// + [Test] + public void CalcGainValue_結果がrangeMin未満_rangeMinを返す() { + int result = GainCalc.CalcGainValue(10, 0.01f, 8, 255); + Assert.AreEqual(8, result); + } + + /// + /// CalcGainValue_比率が負_rangeMinを返す: + /// 負の比率が渡っても負値を設定しないこと + /// + [Test] + public void CalcGainValue_比率が負_rangeMinを返す() { + int result = GainCalc.CalcGainValue(100, -3.0f, 0, 255); + Assert.AreEqual(0, result); + } + + // --------------------------------------------------------------- + // 非有限値(ゼロ除算由来)の再現テスト + // --------------------------------------------------------------- + + /// + /// CalcGainValue_比率がInfinity_rangeMaxを返す: + /// 計測値 0(照明 OFF 等)では CalcRatio が Infinity を返す + /// (GainCalcTests.CalcRatio_value0_戻り値はInfinityまたはNaN_例外は出ない). + /// このとき float→int キャストは未定義値(int.MinValue)になるため, + /// 範囲上限に飽和させること + /// + [Test] + public void CalcGainValue_比率がInfinity_rangeMaxを返す() { + int result = GainCalc.CalcGainValue(133, float.PositiveInfinity, 0, 255); + Assert.AreEqual(255, result); + } + + /// + /// CalcGainValue_比率が負のInfinity_rangeMinを返す + /// + [Test] + public void CalcGainValue_比率が負のInfinity_rangeMinを返す() { + int result = GainCalc.CalcGainValue(133, float.NegativeInfinity, 0, 255); + Assert.AreEqual(0, result); + } + + /// + /// CalcGainValue_比率がNaN_現在値を維持する: + /// 計測値 0・目標値 0 では比率が NaN になり更新量を決められないため, + /// 現在値を維持する(未定義値を設定しない) + /// + [Test] + public void CalcGainValue_比率がNaN_現在値を維持する() { + int result = GainCalc.CalcGainValue(133, float.NaN, 0, 255); + Assert.AreEqual(133, result); + } + + // --------------------------------------------------------------- + // CalcRatio との結合(実際の校正経路の再現) + // --------------------------------------------------------------- + + /// + /// CalcGainValue_CalcRatioの計測値0の結果を渡す_範囲内に収まる: + /// 校正フローと同じく CalcRatio の戻り値をそのまま渡しても + /// 設定値が必ず [rangeMin, rangeMax] に収まること + /// + [Test] + public void CalcGainValue_CalcRatioの計測値0の結果を渡す_範囲内に収まる() { + float ratio = GainCalc.CalcRatio(0.0f, 226.0f, 0.8f); + int result = GainCalc.CalcGainValue(133, ratio, 0, 255); + Assert.That(result, Is.InRange(0, 255)); + } + } +} diff --git a/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs b/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs new file mode 100644 index 0000000..39f93e6 --- /dev/null +++ b/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// IoUtil.CombineSaveFilePath のテスト. + /// 設定ファイル(config.xml の File/*)由来のファイル名が保存フォルダと結合されるとき, + /// 絶対パス指定・..\ トラバーサルでも保存先が保存フォルダの外へ出ないこと,および + /// 同梱 config.xml の既存値では結合結果が従来の Path.Combine と完全一致すること + /// (挙動不変の特性化)を検証する. + /// + [TestFixture] + public class IoUtilCombineSaveFilePathTests { + + private readonly string _saveFolder = @"C:\TIAS_Data\2026-08-03\12_00_00-subject"; + + /// + /// 結合結果が保存フォルダ配下かどうかを判定する(.. は正規化して評価) + /// + private bool IsUnderSaveFolder(string path) { + var full = Path.GetFullPath(path); + var baseFull = Path.GetFullPath(_saveFolder) + .TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + return full.StartsWith(baseFull, StringComparison.OrdinalIgnoreCase); + } + + /// + /// ファイル名に絶対パスを指定しても保存先が保存フォルダの外に出ない + /// (Path.Combine は第2引数が絶対パスだと第1引数を破棄する) + /// + [Test] + public void CombineSaveFilePath_絶対パス指定_保存フォルダ配下に留まる() { + // Arrange: スタートアップ等の任意の場所への書き込みを試みる設定値 + var filename = @"C:\Users\victim\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\run.csv"; + + // Act + var result = IoUtil.CombineSaveFilePath(_saveFolder, filename); + + // Assert + Assert.IsTrue(IsUnderSaveFolder(result), + $"保存先が保存フォルダの外を指している: {Path.GetFullPath(result)}"); + } + + /// + /// ファイル名に ..\ トラバーサルを含めても保存先が保存フォルダの外に出ない + /// + [Test] + public void CombineSaveFilePath_トラバーサル指定_保存フォルダ配下に留まる() { + // Arrange + var filename = @"..\..\..\..\x.png"; + + // Act + var result = IoUtil.CombineSaveFilePath(_saveFolder, filename); + + // Assert + Assert.IsTrue(IsUnderSaveFolder(result), + $"保存先が保存フォルダの外を指している: {Path.GetFullPath(result)}"); + } + + /// + /// 同梱 config.xml の File/* 既存値({CN}/{NO} 置換後の実値)では + /// 結合結果が従来の Path.Combine と完全一致する(挙動不変の特性化) + /// + [TestCase("Shot0001.png")] // File/RgbImage({NO} 置換後) + [TestCase("sRGB17_0001.jpg")] // File/SrgbImage({CN}/{NO} 置換後) + [TestCase("Info.csv")] // File/Info + [TestCase("TCC.png")] // File/TccImage + [TestCase("TCC_ROIs.jpg")] // File/TccRoisImage + [TestCase("TCC_RGB.csv")] // File/TccRgbValues + [TestCase("TCC_sRGB17.csv")] // File/TccSrgbValues({CN} 置換後) + [TestCase("Conv_RGB-sRGB17.csv")] // File/ConvRgb2Srgb({CN} 置換後) + [TestCase("Conv_RGB-XYZ17.csv")] // File/ConvRgb2Xyz({CN} 置換後) + [TestCase("Conv_sRGB-XYZ17.csv")] // File/ConvSrgb2Xyz({CN} 置換後) + [TestCase("Conv_RGB-XYZold.csv")] // File/ConvRgb2XyzOld + public void CombineSaveFilePath_同梱configの既存値_従来のPathCombineと一致する(string filename) { + // Act + var result = IoUtil.CombineSaveFilePath(_saveFolder, filename); + + // Assert + Assert.AreEqual(Path.Combine(_saveFolder, filename), result, + "同梱 config.xml の既存値では結合結果が従来と完全一致すること"); + } + } +} diff --git a/TIASshot.Tests/IoUtilCreateSaveFolderTests.cs b/TIASshot.Tests/IoUtilCreateSaveFolderTests.cs new file mode 100644 index 0000000..c0c1627 --- /dev/null +++ b/TIASshot.Tests/IoUtilCreateSaveFolderTests.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// IoUtil.CreateSaveFolder のテスト. + /// データ名(ユーザー入力)がフォルダ名要素として無害化され, + /// 保存先が設定ベースフォルダ(File/SaveFolder)の外へ出ないことを検証する. + /// + [TestFixture] + public class IoUtilCreateSaveFolderTests { + + private string _rootDir; // テスト用ルート(脱出検知用の外側) + private string _baseSaveFolder; // config の File/SaveFolder に設定するベース + private string _tempConfigPath; + + [SetUp] + public void SetUp() { + // 脱出をルート内に閉じ込めて観測できるよう,ベースを深い位置に置く + _rootDir = Path.Combine(Path.GetTempPath(), "TIASshotTests_" + Guid.NewGuid().ToString("N")); + _baseSaveFolder = Path.Combine(_rootDir, "outer", "SaveFolder"); + Directory.CreateDirectory(_baseSaveFolder); + + _tempConfigPath = Path.Combine(_rootDir, "config.xml"); + var xml = + "" + + "" + _baseSaveFolder + ""; + File.WriteAllText(_tempConfigPath, xml, System.Text.Encoding.UTF8); + Config.Load(_tempConfigPath); + } + + [TearDown] + public void TearDown() { + if (Directory.Exists(_rootDir)) Directory.Delete(_rootDir, true); + } + + /// + /// 生成されたフォルダがベースフォルダ配下かどうかを判定する + /// + private bool IsUnderBase(string path) { + var full = Path.GetFullPath(path); + var baseFull = Path.GetFullPath(_baseSaveFolder) + .TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + return full.StartsWith(baseFull, StringComparison.OrdinalIgnoreCase); + } + + /// + /// データ名に ..\ トラバーサルを含めても保存先がベースフォルダの外へ出ない + /// + [Test] + public void CreateSaveFolder_トラバーサル入力_ベースフォルダ配下に留まる() { + // Arrange: ".." 4 個で SaveFolder\日付\時刻- の 3 段を超えて脱出を試みる入力 + var note = @"..\..\..\..\evil"; + + // Act + var result = IoUtil.CreateSaveFolder(note); + + // Assert + Assert.IsTrue(IsUnderBase(result), + $"保存フォルダがベースフォルダの外に生成された: {Path.GetFullPath(result)}"); + Assert.IsFalse(Directory.Exists(Path.Combine(_rootDir, "evil")), + "ベースフォルダ外に 'evil' ディレクトリが作られてはならない"); + } + + /// + /// データ名に不正なファイル名文字(: ? 等)を含めても例外にならず,配下に生成される + /// + [Test] + public void CreateSaveFolder_不正文字入力_例外を出さず配下に生成() { + // Arrange + var note = "a:b?c"; + + // Act + string result = null; + Assert.DoesNotThrow(() => { result = IoUtil.CreateSaveFolder(note); }, + "不正文字を含むデータ名で例外が発生してはならない(撮影中断の可用性影響)"); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(Directory.Exists(result), "保存フォルダが生成されていること"); + Assert.IsTrue(IsUnderBase(result), "保存フォルダはベースフォルダ配下であること"); + } + + /// + /// 通常のデータ名(安全な文字のみ)はそのままフォルダ名末尾に使われる(現状挙動の固定) + /// + [Test] + public void CreateSaveFolder_通常入力_データ名がフォルダ名に保持される() { + // Arrange + var note = "subject01"; + + // Act + var result = IoUtil.CreateSaveFolder(note); + + // Assert + Assert.IsTrue(Directory.Exists(result), "保存フォルダが生成されていること"); + Assert.IsTrue(IsUnderBase(result), "保存フォルダはベースフォルダ配下であること"); + StringAssert.EndsWith("-subject01", Path.GetFileName(result), + "フォルダ名末尾にデータ名がそのまま保持されること"); + } + } +} diff --git a/TIASshot.Tests/IoUtilSaveImageTests.cs b/TIASshot.Tests/IoUtilSaveImageTests.cs new file mode 100644 index 0000000..4103e30 --- /dev/null +++ b/TIASshot.Tests/IoUtilSaveImageTests.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using NUnit.Framework; +using OpenCvSharp; + +namespace TIASshot.Tests { + /// + /// IoUtil.SaveImage のテスト. + /// Cv2.ImWrite は保存失敗(存在しないフォルダ・ディスク満杯・権限等)で例外を出さず + /// false を返すだけであり,戻り値を検査しないと保存失敗が無通知で成功扱いになる. + /// SaveImage が失敗を false として正しく報告すること,および正常パスでは + /// true を返して実際にファイルが生成されること(従来挙動の特性化)を検証する. + /// + [TestFixture] + public class IoUtilSaveImageTests { + + private string _tempFolder; + + [SetUp] + public void SetUp() { + _tempFolder = Path.Combine(Path.GetTempPath(), "TIASshotTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempFolder); + } + + [TearDown] + public void TearDown() { + if (Directory.Exists(_tempFolder)) { + Directory.Delete(_tempFolder, recursive: true); + } + } + + /// + /// 存在しないフォルダへの保存は false を返す(保存失敗が無通知で成功扱いにならない) + /// + [Test] + public void SaveImage_存在しないフォルダ_falseを返す() { + // Arrange + var path = Path.Combine(_tempFolder, "no_such_dir", "out.png"); + using (var img = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(128))) { + + // Act + var result = IoUtil.SaveImage(path, img); + + // Assert + Assert.IsFalse(result, "存在しないフォルダへの保存失敗が false で報告されること"); + Assert.IsFalse(File.Exists(path), "ファイルが生成されていないこと"); + } + } + + /// + /// 正常なパスへの保存は true を返しファイルが生成される(従来挙動の特性化) + /// + [Test] + public void SaveImage_正常なパス_trueを返しファイルが生成される() { + // Arrange + var path = Path.Combine(_tempFolder, "out.png"); + using (var img = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(128))) { + + // Act + var result = IoUtil.SaveImage(path, img); + + // Assert + Assert.IsTrue(result, "正常な保存では true を返すこと"); + Assert.IsTrue(File.Exists(path), "ファイルが生成されていること"); + } + } + } +} diff --git a/TIASshot.Tests/IoUtilTests.cs b/TIASshot.Tests/IoUtilTests.cs index 5ff291e..d770ef5 100644 --- a/TIASshot.Tests/IoUtilTests.cs +++ b/TIASshot.Tests/IoUtilTests.cs @@ -70,6 +70,114 @@ } /// + /// 再現テスト: 小数点にカンマを使うロケール(de-DE)でもピリオド小数の CSV を正しく解釈する + /// (同梱 tcc_srgb.csv 等はピリオド小数で記述される仕様のため,パースはロケール非依存であること. + /// カルチャ依存だと "96.32301186" が桁区切り扱いで 9632301186 に化け,例外にならず校正が黙って壊れる) + /// + [Test] + [NonParallelizable] + public void LoadMatFromCsv_カンマ小数ロケール_ピリオド小数を正しく解釈する() { + // Arrange + File.WriteAllText(_csvPath, "96.32301186,112.325025,186.5234201\r\n"); + var original = System.Threading.Thread.CurrentThread.CurrentCulture; + try { + System.Threading.Thread.CurrentThread.CurrentCulture = + new System.Globalization.CultureInfo("de-DE"); + + // Act + using (var loaded = IoUtil.LoadMatFromCsv(_csvPath)) { + // Assert + Assert.IsNotNull(loaded); + Assert.AreEqual(96.32301186, loaded.At(0, 0), 1e-6); + Assert.AreEqual(112.325025, loaded.At(0, 1), 1e-6); + Assert.AreEqual(186.5234201, loaded.At(0, 2), 1e-6); + } + } finally { + System.Threading.Thread.CurrentThread.CurrentCulture = original; + } + } + + /// + /// 再現テスト: 小数点にカンマを使うロケール(de-DE)でもピリオド小数で CSV を出力する + /// (カルチャ依存だと小数点がカンマになり,列区切りのカンマと衝突して CSV が壊れる) + /// + [Test] + [NonParallelizable] + public void SaveMatToCsv_カンマ小数ロケール_ピリオド小数で出力する() { + // Arrange + var original = System.Threading.Thread.CurrentThread.CurrentCulture; + try { + System.Threading.Thread.CurrentThread.CurrentCulture = + new System.Globalization.CultureInfo("de-DE"); + using (var src = new Mat(1, 2, MatType.CV_64FC1)) { + src.At(0, 0) = 1.2345678; + src.At(0, 1) = -2.5; + + // Act + IoUtil.SaveMatToCsv(_csvPath, src); + + // Assert + var line = File.ReadAllLines(_csvPath)[0]; + Assert.AreEqual("1.2345678,-2.5000000", line, + "ロケールによらずピリオド小数・カンマ列区切りで出力すること"); + } + } finally { + System.Threading.Thread.CurrentThread.CurrentCulture = original; + } + } + + /// + /// 特性化テスト: ドット小数ロケール(ja-JP)での出力バイト列を固定する + /// (InvariantCulture 固定化の修正で従来ロケールの出力が 1 バイトも変わらない保護) + /// + [Test] + [NonParallelizable] + public void SaveMatToCsv_ドット小数ロケール_出力が従来と同一() { + // Arrange + var original = System.Threading.Thread.CurrentThread.CurrentCulture; + try { + System.Threading.Thread.CurrentThread.CurrentCulture = + new System.Globalization.CultureInfo("ja-JP"); + using (var src = CreateMat2x3()) { + // Act + IoUtil.SaveMatToCsv(_csvPath, src); + + // Assert + var lines = File.ReadAllLines(_csvPath); + Assert.AreEqual("1.2345670,-2.3456780,0.0000010", lines[0]); + Assert.AreEqual("100.0000000,-0.5000000,999.9999990", lines[1]); + } + } finally { + System.Threading.Thread.CurrentThread.CurrentCulture = original; + } + } + + /// + /// 特性化テスト: リポジトリ同梱の tcc_srgb.csv / tcc_xyz.csv が従来どおり読めること + /// (CameraBase の TCC 参照値読み込み経路の保護.先頭要素の値と行列サイズを固定する) + /// + [Test] + public void LoadMatFromCsv_同梱tccCsv_従来どおり読める() { + var repoDir = Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", "..", "..", "..", "TIASshot")); + var srgbPath = Path.Combine(repoDir, "tcc_srgb.csv"); + var xyzPath = Path.Combine(repoDir, "tcc_xyz.csv"); + Assert.IsTrue(File.Exists(srgbPath), $"同梱 tcc_srgb.csv が見つからない: {srgbPath}"); + Assert.IsTrue(File.Exists(xyzPath), $"同梱 tcc_xyz.csv が見つからない: {xyzPath}"); + + using (var srgb = IoUtil.LoadMatFromCsv(srgbPath)) + using (var xyz = IoUtil.LoadMatFromCsv(xyzPath)) { + Assert.IsNotNull(srgb, "tcc_srgb.csv が読めること"); + Assert.IsNotNull(xyz, "tcc_xyz.csv が読めること"); + Assert.AreEqual(3, srgb.Cols, "tcc_srgb.csv は 3 列であること"); + Assert.AreEqual(3, xyz.Cols, "tcc_xyz.csv は 3 列であること"); + Assert.AreEqual(96.32301186, srgb.At(0, 0), 1e-6, + "tcc_srgb.csv の先頭要素が従来値で読めること"); + } + } + + /// /// 存在しないファイルをロードしたとき null を返す /// [Test] diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index 829bc8b..5aec33b 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -56,9 +56,13 @@ + + + + diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index d90bce6..d4709ad 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -64,6 +64,13 @@ protected Rect _roi; protected Thread _saveThread = null; // SaveThreadへの参照を保持(前回完了待機用) protected ManualResetEventSlim _saveThreadReady = new ManualResetEventSlim(false); // SaveThread起動完了の同期 + // 撮影が途中で中止されたか.撮影スレッド(RunShotLoop)が書き,別スレッドの SaveThread が + // 読むため volatile とする(_abortReason とは可視性の前提が異なるため統合しない). + protected volatile bool _shotAborted = false; + // 中止理由(EndShot 完了後にユーザー通知するため保持する.通知時にクリアする). + // 【前提】RunShotLoop(書き手)も ShotOne/ShotMulti の通知(読み手)も同一の UI スレッドで + // 同期的に実行されるため volatile は不要.Shot をワーカースレッド化する場合は要見直し. + private string _abortReason = null; // プライベートメンバ readonly float UpdateRate; @@ -115,8 +122,7 @@ SetInfo("データ名", _form.GetDataName()); SetInfo("撮影枚数", "1"); Shot(); - IoUtil.PlaySound(Config.GetString("Sound/OneShotDone")); - _form.ShowMessage("撮影終了"); + FinishShot("Sound/OneShotDone"); } /// @@ -128,11 +134,39 @@ SetInfo("撮影枚数", $"{_form.GetNumMultiShots()}"); SetInfo("撮影間隔(ms)", $"{_form.GetMultiShotsInterval()}"); Shot(_form.GetNumMultiShots(), _form.GetMultiShotsInterval()); - IoUtil.PlaySound(Config.GetString("Sound/MultiShotDone")); + FinishShot("Sound/MultiShotDone"); + } + + /// + /// 撮影完了後の後処理(Shot から戻った直後に呼ぶこと). + /// 正常終了時は完了音を鳴らして「撮影終了」を表示する. + /// 中止時は完了音・完了メッセージを出さず(SaveThread が中止を表示する),中止理由を通知する. + /// + /// 完了音の Config キー + private void FinishShot(string soundConfigKey) { + if (_shotAborted) { + NotifyShotAborted(); + return; + } + IoUtil.PlaySound(Config.GetString(soundConfigKey)); _form.ShowMessage("撮影終了"); } /// + /// 撮影中止をユーザーへ通知する(撮影終了処理 EndShot の完了後に呼ぶこと). + /// MessageBox はモーダルループ内でメッセージをポンプするため,カメラの状態遷移 + /// (Lucam: LucamDisableFastFrames / IScam: LiveStop+Sink 復帰)が終わる前に表示すると, + /// 撮影ボタン再有効化と相まって Shot がネスト実行され得る. + /// 通知後に理由をクリアし,二重通知を防ぐ. + /// + private void NotifyShotAborted() { + var reason = _abortReason; + _abortReason = null; + if (reason == null) return; // 想定外例外による中止(理由なし)は通知対象外 + _form.ShowError($"{reason}\r\n撮影を中止しました.取得済みの画像のみ保存します."); + } + + /// /// 画像保存処理 /// /// @@ -141,7 +175,7 @@ var filename = Config.GetString("File/RgbImage"); filename = filename.Replace("{NO}", $"{idx + 1:0000}"); - Cv2.ImWrite(Path.Combine(_saveFolder, filename), img); + IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, filename), img); foreach (var channel in _colorCorrector.Channels) { // ConvertImage は src を in-place で CV_64FC3 に変換するため、 @@ -152,7 +186,7 @@ using (var converted = _colorCorrector.ConvertImage(imgClone, channel)) { filename = GetFilenameWithChannel("File/SrgbImage", channel); filename = filename.Replace("{NO}", $"{idx + 1:0000}"); - Cv2.ImWrite(Path.Combine(_saveFolder, filename), converted); + IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, filename), converted); } } } @@ -173,7 +207,13 @@ saveCount++; _form.ShowMessage($"画像{saveCount}/{numImages}枚目保存"); } - _form.ShowMessage("全ての画像保存完了"); + // 撮影が中止された場合は「全て保存完了」と表示すると誤解を与えるため, + // 取得できた枚数のみ保存した旨を表示する. + if (_shotAborted) { + _form.ShowMessage($"撮影中止:取得済みの{saveCount}枚のみ保存しました"); + } else { + _form.ShowMessage("全ての画像保存完了"); + } _form.EnableShots(true); } @@ -210,35 +250,44 @@ /// /// 撮影ループの共通骨格(テンプレートメソッド) - /// CSV 書き込み・ループ・タイミング制御を担う. + /// 撮影ログ(Info.csv)の準備と中止状態の管理を担い,ループ本体は CaptureShots に, /// フレーム取得(CaptureFrame)は派生クラスに委譲する. /// BeginShot/EndShot は呼び出し元(派生クラスの Shot)で前後に実行すること. /// ループ完了後に _shots.CompleteAdding() を呼び,SaveThread に終了を通知する. + /// フレーム取得が失敗(CaptureFailedException)した場合は,破損画像の保存を避けるため + /// 残り枚数を撮影せずに即座にループを抜け,取得済みの画像のみを保存させる. + /// 中止理由は _abortReason に保持するだけとし,ユーザー通知は EndShot 完了後に + /// ShotOne/ShotMulti が NotifyShotAborted で行う(モーダル中の Shot 再入を避けるため). /// /// 撮影枚数 /// 撮影間隔(ms) protected void RunShotLoop(int numImages, int interval) { + _shotAborted = false; + _abortReason = null; + string abortReason = null; + bool aborted = false; try { #if MEMMONITOR PreviewProfiler.MarkEvent("SHOT_START"); #endif var filename = Config.GetString("File/Info"); - using (var csv = new StreamWriter(Path.Combine(_saveFolder, filename))) { + using (var csv = new StreamWriter(IoUtil.CombineSaveFilePath(_saveFolder, filename))) { WriteInfo(csv); - csv.WriteLine("Shot,Time(ms)"); - - var watch = Stopwatch.StartNew(); - for (var i = 0; i < numImages; i++) { - var shotTime = watch.ElapsedMilliseconds; - csv.WriteLine($"{i + 1},{shotTime}"); - _form.ShowMessage($"撮影 {i + 1} / {numImages} 枚目"); - _shots.Add(CaptureFrame()); - while (watch.ElapsedMilliseconds < interval * (i + 1) && i < numImages - 1) { - Thread.Sleep(1); - } - } + abortReason = CaptureShots(csv, numImages, interval); + aborted = abortReason != null; } + } catch (Exception ex) { + // CaptureFailedException 以外(StreamWriter 生成失敗,SnapSingle のタイムアウト等)でも + // 撮影枚数は不足しているため中止扱いにし,SaveThread に「全て保存完了」と表示させない. + // 通知手段を持たないため,従来どおり再スローして呼び出し元へ伝播させる. + Debug.WriteLine($"撮影中止(想定外の例外): {ex}"); + aborted = true; + throw; } finally { + // SaveThread が中止有無を判定できるよう,CompleteAdding より先にフラグを立てる. + _shotAborted = aborted; + // 中止理由は EndShot 完了後(ShotOne/ShotMulti)に通知するため保持する. + _abortReason = abortReason; _shots.CompleteAdding(); #if MEMMONITOR PreviewProfiler.MarkEvent("SHOT_END"); @@ -247,6 +296,39 @@ } /// + /// 撮影ループ本体.撮影ログのヘッダ行に続けて,1 枚ずつ取得して撮影時刻を CSV に記録し,_shots へ渡す. + /// フレーム取得に失敗した場合は,破損画像の保存を避けるため残り枚数を撮影せずに打ち切る. + /// + /// 撮影ログ(Info.csv)の書き込み先 + /// 撮影枚数 + /// 撮影間隔(ms) + /// 全枚数を撮影できた場合は null,途中で中止した場合はその理由 + private string CaptureShots(StreamWriter csv, int numImages, int interval) { + csv.WriteLine("Shot,Time(ms),Note"); + + var watch = Stopwatch.StartNew(); + for (var i = 0; i < numImages; i++) { + var shotTime = watch.ElapsedMilliseconds; + _form.ShowMessage($"撮影 {i + 1} / {numImages} 枚目"); + Mat frame; + try { + frame = CaptureFrame(); + } catch (CaptureFailedException ex) { + // 破損データを正常画像として保存しないよう,この枚数以降は撮影しない. + Debug.WriteLine($"撮影中止({i + 1}/{numImages} 枚目): {ex.Message}"); + csv.WriteLine($"{i + 1},{shotTime},撮影失敗のため中止({ex.Message})"); + return ex.Message; + } + csv.WriteLine($"{i + 1},{shotTime}"); + _shots.Add(frame); + while (watch.ElapsedMilliseconds < interval * (i + 1) && i < numImages - 1) { + Thread.Sleep(1); + } + } + return null; + } + + /// /// チャートの検出 /// /// @@ -288,8 +370,8 @@ // データ保存 SetSaveFolder("校正"); SetInfo("校正データ", _saveFolder); - Cv2.ImWrite(Path.Combine(_saveFolder, Config.GetString("File/TccImage")), img); - Cv2.ImWrite(Path.Combine(_saveFolder, Config.GetString("File/TccRoisImage")), imgRois); + IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccImage")), img); + IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccRoisImage")), imgRois); // 変換行列の計算・保存は ColorCorrector に委譲する(tccRgb の所有権は渡さない) _colorCorrector.CalcTcc(tccRgb, _saveFolder, IoUtil.SaveMatToCsv); diff --git a/TIASshot/Cameras/CaptureFailedException.cs b/TIASshot/Cameras/CaptureFailedException.cs new file mode 100644 index 0000000..7871efb --- /dev/null +++ b/TIASshot/Cameras/CaptureFailedException.cs @@ -0,0 +1,18 @@ +using System; + +namespace TIASshot { + + /// + /// カメラ SDK のフレーム取得・変換が失敗したことを表す例外. + /// 失敗したフレームのデータは不正(ゼロ埋め・前フレームの残骸)であり, + /// 保存すると臨床データが無通知で破損するため,撮影を即座に中止する目的で使用する. + /// CameraBase.RunShotLoop が捕捉し,撮影ループを打ち切ってユーザーに通知する. + /// + internal class CaptureFailedException : Exception { + + public CaptureFailedException(string message) : base(message) { } + + public CaptureFailedException(string message, Exception innerException) + : base(message, innerException) { } + } +} diff --git a/TIASshot/Cameras/GainCalc.cs b/TIASshot/Cameras/GainCalc.cs index 2695fd5..9854e5d 100644 --- a/TIASshot/Cameras/GainCalc.cs +++ b/TIASshot/Cameras/GainCalc.cs @@ -17,5 +17,26 @@ ratio = (ratio - 1.0f) * (value == 255.0f ? 1.0f : updateRate) + 1.0f; return ratio; } + + /// + /// 現在のゲイン値に更新比率を掛けた新しいゲイン設定値を求める. + /// ハードウェアが受け付ける範囲 [rangeMin, rangeMax] に収めて返す. + /// + /// 現在のゲイン設定値 + /// 更新比率( の戻り値) + /// 設定可能な最小値 + /// 設定可能な最大値 + /// 範囲内に収めた新しいゲイン設定値 + internal static int CalcGainValue(int currentValue, float ratio, int rangeMin, int rangeMax) { + // 計測値・目標値がともに 0 のとき比率は NaN になり更新量を決められないため現在値を維持する + if (float.IsNaN(ratio)) return currentValue; + + // float→int キャストは範囲外・非有限値(Infinity)で未定義値になるため, + // キャスト前に double のまま範囲判定する + double value = 0.5 + (double)(currentValue * ratio); + if (value < rangeMin) return rangeMin; + if (value > rangeMax) return rangeMax; + return (int)value; + } } } diff --git a/TIASshot/Cameras/IScam.cs b/TIASshot/Cameras/IScam.cs index da3b09a..9e125f2 100644 --- a/TIASshot/Cameras/IScam.cs +++ b/TIASshot/Cameras/IScam.cs @@ -123,9 +123,18 @@ /// /// 白パッチの計測値 protected override void ApplyWhiteBalance(Scalar whitePatch) { - _whiteBalanceBlue.Value = (int)(0.5 + _whiteBalanceBlue.Value * GetRatio((float)whitePatch.Val0, Config.GetFloat("Calib/Reference/B"))); - _whiteBalanceGreen.Value = (int)(0.5 + _whiteBalanceGreen.Value * GetRatio((float)whitePatch.Val1, Config.GetFloat("Calib/Reference/G"))); - _whiteBalanceRed.Value = (int)(0.5 + _whiteBalanceRed.Value * GetRatio((float)whitePatch.Val2, Config.GetFloat("Calib/Reference/R"))); + _whiteBalanceBlue.Value = GainCalc.CalcGainValue( + _whiteBalanceBlue.Value, + GetRatio((float)whitePatch.Val0, Config.GetFloat("Calib/Reference/B")), + _whiteBalanceBlue.RangeMin, _whiteBalanceBlue.RangeMax); + _whiteBalanceGreen.Value = GainCalc.CalcGainValue( + _whiteBalanceGreen.Value, + GetRatio((float)whitePatch.Val1, Config.GetFloat("Calib/Reference/G")), + _whiteBalanceGreen.RangeMin, _whiteBalanceGreen.RangeMax); + _whiteBalanceRed.Value = GainCalc.CalcGainValue( + _whiteBalanceRed.Value, + GetRatio((float)whitePatch.Val2, Config.GetFloat("Calib/Reference/R")), + _whiteBalanceRed.RangeMin, _whiteBalanceRed.RangeMax); } /// diff --git a/TIASshot/Cameras/Lucam.cs b/TIASshot/Cameras/Lucam.cs index 961d32d..21248d0 100644 --- a/TIASshot/Cameras/Lucam.cs +++ b/TIASshot/Cameras/Lucam.cs @@ -264,12 +264,25 @@ /// 1 フレーム取得(Lucam 固有) /// /// 撮影した Mat(呼び出し元が _shots に追加する) + /// + /// SDK のフレーム取得・RGB 変換が失敗した場合. + /// 失敗時の _rawImage / _rgbImage は不正なデータ(ゼロ埋め,または前フレームの残骸)であり, + /// そのまま保存すると破損した画像が正常画像として記録されるため,撮影を中止させる. + /// protected override Mat CaptureFrame() { - var ret = dll.LucamTakeFastFrame(_hCam, _rawImage); - //Debug.WriteLine(ret); + if (!dll.LucamTakeFastFrame(_hCam, _rawImage)) { + var err = dll.LucamGetLastErrorForCamera(_hCam); + Debug.WriteLine($"LucamTakeFastFrame 失敗: {err}"); + throw new CaptureFailedException($"カメラからの画像取得に失敗しました({err})"); + } - dll.LucamConvertFrameToRgb24(_hCam, _rgbImage, _rawImage, - _snap.Format.Width, _snap.Format.Height, dll.LucamPixelFormat.PF_8, ref _convert); + if (!dll.LucamConvertFrameToRgb24(_hCam, _rgbImage, _rawImage, + _snap.Format.Width, _snap.Format.Height, dll.LucamPixelFormat.PF_8, ref _convert)) { + var err = dll.LucamGetLastErrorForCamera(_hCam); + Debug.WriteLine($"LucamConvertFrameToRgb24 失敗: {err}"); + throw new CaptureFailedException($"取得した画像のRGB変換に失敗しました({err})"); + } + using (Mat img = Mat.FromPixelData(_snap.Format.Height, _snap.Format.Width, MatType.CV_8UC3, _rgbImage)) { return img.T(); } diff --git a/TIASshot/ColorCorrection/ColorCorrector.cs b/TIASshot/ColorCorrection/ColorCorrector.cs index eadebea..4c37829 100644 --- a/TIASshot/ColorCorrection/ColorCorrector.cs +++ b/TIASshot/ColorCorrection/ColorCorrector.cs @@ -89,7 +89,7 @@ /// Mat を CSV へ保存する処理(呼び出し側が提供) public void CalcTcc(Mat tccRgb, string saveFolder, Action saveMatToCsv) { - saveMatToCsv(Path.Combine(saveFolder, Config.GetString("File/TccRgbValues")), tccRgb); + saveMatToCsv(IoUtil.CombineSaveFilePath(saveFolder, Config.GetString("File/TccRgbValues")), tccRgb); // 再校正時,古い変換行列のネイティブ資源を解放してから作り直す(Mat リーク防止) foreach (var mat in _convRGB2SRGB.Values) { @@ -99,11 +99,11 @@ foreach (var channel in ChannelList) { var convRGB2SRGB = CalcConvertMatrix(tccRgb, TCC_SRGB, channel); - saveMatToCsv(Path.Combine(saveFolder, GetFilenameWithChannel("File/ConvRgb2Srgb", channel)), convRGB2SRGB); + saveMatToCsv(IoUtil.CombineSaveFilePath(saveFolder, GetFilenameWithChannel("File/ConvRgb2Srgb", channel)), convRGB2SRGB); // 以降の中間 Mat は辞書に保持しないため,使用後に確実に解放する using (var convRGB2XYZ = CalcConvertMatrix(tccRgb, TCC_XYZ, channel)) { - saveMatToCsv(Path.Combine(saveFolder, GetFilenameWithChannel("File/ConvRgb2Xyz", channel)), convRGB2XYZ); + saveMatToCsv(IoUtil.CombineSaveFilePath(saveFolder, GetFilenameWithChannel("File/ConvRgb2Xyz", channel)), convRGB2XYZ); if (channel == 17) { using (var convOld = new Mat(convRGB2XYZ.Size(), MatType.CV_64FC1)) { for (var row = 0; row < convRGB2XYZ.Rows; row++) { @@ -112,16 +112,16 @@ convOld.At(row, col) = convRGB2XYZ.At(rowFrom, col); } } - saveMatToCsv(Path.Combine(saveFolder, GetFilenameWithChannel("File/ConvRgb2XyzOld", channel)), convOld); + saveMatToCsv(IoUtil.CombineSaveFilePath(saveFolder, GetFilenameWithChannel("File/ConvRgb2XyzOld", channel)), convOld); } } } using (var tccSrgb = ConvertColor(tccRgb, convRGB2SRGB)) { - saveMatToCsv(Path.Combine(saveFolder, GetFilenameWithChannel("File/TccSrgbValues", channel)), tccSrgb); + saveMatToCsv(IoUtil.CombineSaveFilePath(saveFolder, GetFilenameWithChannel("File/TccSrgbValues", channel)), tccSrgb); using (var convSrgb2Xyz = CalcConvertMatrix(tccSrgb, TCC_XYZ, channel)) { - saveMatToCsv(Path.Combine(saveFolder, GetFilenameWithChannel("File/ConvSrgb2Xyz", channel)), convSrgb2Xyz); + saveMatToCsv(IoUtil.CombineSaveFilePath(saveFolder, GetFilenameWithChannel("File/ConvSrgb2Xyz", channel)), convSrgb2Xyz); } // 変換精度検証 @@ -162,10 +162,15 @@ /// 画像 /// 変換目標の24x3行列 internal Mat CalcConvertMatrix(Mat src, Mat target, int channels) { + // ExtendMat は非対応入力で src をそのまま返すため,新規確保時のみ解放する var extended = ExtendMat(src, channels); - Mat convMat = new Mat(channels, 3, MatType.CV_64FC1); - Cv2.Solve(extended, target, convMat, DecompTypes.SVD); - return convMat; + try { + Mat convMat = new Mat(channels, 3, MatType.CV_64FC1); + Cv2.Solve(extended, target, convMat, DecompTypes.SVD); + return convMat; + } finally { + if (!ReferenceEquals(extended, src)) extended.Dispose(); + } } /// @@ -175,9 +180,15 @@ /// /// internal Mat ConvertColor(Mat src, Mat conv) { + // ExtendMat は非対応入力で src をそのまま返すため,新規確保時のみ解放する var extended = ExtendMat(src, conv.Rows); - var converted = (extended * conv); - return converted.ToMat(); + try { + using (var converted = extended * conv) { + return converted.ToMat(); + } + } finally { + if (!ReferenceEquals(extended, src)) extended.Dispose(); + } } /// @@ -217,11 +228,13 @@ } /// - /// 行列の拡張 3次元→高次元(指定チャンネル数) + /// 行列の拡張 3次元→高次元(指定チャンネル数). + /// 3 列相当でない入力は拡張せず src をそのまま返す(呼び出し側は参照同一性で解放を判断する). + /// virtual はテスト用シーム(戻り値 Mat の解放検証のため.挙動は不変). /// /// /// - internal Mat ExtendMat(Mat src, int channels) { + internal virtual Mat ExtendMat(Mat src, int channels) { if (src.Cols * src.Channels() != 3) return src; var dst = new Mat(src.Rows, channels, MatType.CV_64FC1); Parallel.For(0, src.Rows, row => { diff --git a/TIASshot/Config.cs b/TIASshot/Config.cs index 9431682..0ab0e24 100644 --- a/TIASshot/Config.cs +++ b/TIASshot/Config.cs @@ -46,14 +46,14 @@ /// /// public static float GetFloat(string param) { - float value = 0.0f; XmlNode node = doc.SelectSingleNode($"//Config/{param}"); if (node == null) throw new ArgumentException($"Parameter '{param}' not found in config file."); - if (node != null && float.TryParse(node.InnerText, out float result)) { - value = result; + if (!float.TryParse(node.InnerText, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float result)) { + throw new ArgumentException($"Parameter '{param}' value '{node.InnerText}' is not a valid float."); } - return value; + return result; } /// @@ -62,14 +62,14 @@ /// /// public static int GetInt(string param) { - int value = 0; XmlNode node = doc.SelectSingleNode($"//Config/{param}"); if (node == null) throw new ArgumentException($"Parameter '{param}' not found in config file."); - if (node != null && int.TryParse(node.InnerText, out int result)) { - value = result; + if (!int.TryParse(node.InnerText, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out int result)) { + throw new ArgumentException($"Parameter '{param}' value '{node.InnerText}' is not a valid int."); } - return value; + return result; } /// diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index 3aae01d..5d011ee 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -120,6 +120,7 @@ + diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index 433df66..01d811c 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -252,6 +252,20 @@ } /// + /// エラー通知(モーダルダイアログ) + /// 撮影中止など,ステータス表示(ShowMessage)では見落とされては困る事象をユーザーに明示通知する. + /// 撮影スレッド・保存スレッドから呼ばれるため UI スレッドへマーシャリングする. + /// + /// 表示するメッセージ + public void ShowError(string msg) { + if (InvokeRequired) { + Invoke((MethodInvoker)delegate { ShowError(msg); }); + return; + } + MessageBox.Show(msg, APP_NAME, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + + /// /// データ名取得 /// /// diff --git a/TIASshot/Utils/IoUtil.cs b/TIASshot/Utils/IoUtil.cs index 0d7c41e..95f9d16 100644 --- a/TIASshot/Utils/IoUtil.cs +++ b/TIASshot/Utils/IoUtil.cs @@ -29,7 +29,9 @@ var valStrs = line.Split(','); if (cols == -1) cols = valStrs.Length; else if (cols != valStrs.Length) throw new Exception("cols != valStrs.Length"); - var vals = valStrs.Select(x => double.Parse(x)).ToArray(); + // CSV はピリオド小数で記述される仕様のため,OS ロケールに依存せず解釈する + var vals = valStrs.Select( + x => double.Parse(x, System.Globalization.CultureInfo.InvariantCulture)).ToArray(); arr.Add(vals); } } @@ -56,7 +58,9 @@ var line = ""; for (var col = 0; col < m.Cols; col++) { if (col > 0) line += ","; - line += $"{m.At(row, col):0.0000000}"; + // カンマ小数ロケールでは小数点が列区切りと衝突するため,常にピリオド小数で出力する + line += m.At(row, col).ToString( + "0.0000000", System.Globalization.CultureInfo.InvariantCulture); } writer.WriteLine(line); } @@ -73,7 +77,7 @@ var paths = new List() { Config.GetString("File/SaveFolder"), dt.ToString("yyyy-MM-dd"), - dt.ToString("HH_mm_ss") + $"-{note}", + dt.ToString("HH_mm_ss") + $"-{SanitizeFolderNameElement(note)}", }; var saveFolder = Path.Combine(paths.ToArray()); Directory.CreateDirectory(saveFolder); @@ -81,6 +85,52 @@ } /// + /// ユーザー入力をフォルダ名の一要素として安全に使えるよう無害化する. + /// パス区切り(\ /)・ドライブ区切り(:)を含む不正なファイル名文字を _ に置換し, + /// 保存先がベースフォルダの外へ出る(パストラバーサル)・例外で撮影が中断することを防ぐ. + /// + /// ユーザー入力の名前(null 可) + /// 不正文字を _ に置換した文字列 + internal static string SanitizeFolderNameElement(string name) { + if (string.IsNullOrEmpty(name)) return ""; + var invalidChars = Path.GetInvalidFileNameChars(); + var chars = name.ToCharArray(); + for (var i = 0; i < chars.Length; i++) { + if (Array.IndexOf(invalidChars, chars[i]) >= 0) chars[i] = '_'; + } + return new string(chars); + } + + /// + /// 設定ファイル由来のファイル名を保存フォルダと結合し,保存先ファイルパスを返す. + /// ファイル名に含まれる不正なファイル名文字(パス区切り \ /・ドライブ区切り : 等)を + /// _ に置換してから結合するため,設定の破損・誤設定(絶対パス指定・..\ トラバーサル)でも + /// 保存先が保存フォルダの外へ出ない.同梱 config.xml の既存値では従来の + /// Path.Combine と完全に同一の結果を返す(挙動不変). + /// + /// 保存フォルダのパス + /// 設定ファイル由来のファイル名({CN}/{NO} 置換後) + /// 保存先ファイルパス(必ず saveFolder 配下) + public static string CombineSaveFilePath(string saveFolder, string filename) { + return Path.Combine(saveFolder, SanitizeFolderNameElement(filename)); + } + + /// + /// 画像をファイルに保存する. + /// Cv2.ImWrite は保存失敗(ディスク満杯・パス不正・権限等)で例外を出さず false を + /// 返すだけのため,戻り値を検査して失敗を Debug.WriteLine に記録し bool で返す. + /// 例外(拡張子不正等)は捕捉せず伝播する(従来挙動どおり). + /// + /// 保存先ファイルパス + /// 保存する画像 + /// 保存に成功したら true + public static bool SaveImage(string path, Mat img) { + var ok = Cv2.ImWrite(path, img); + if (!ok) Debug.WriteLine($"画像の保存に失敗しました: {path}"); + return ok; + } + + /// /// 設定値をCSVファイルに書き込む /// /// diff --git "a/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" index 7feab10..8b28a37 100644 --- "a/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" @@ -64,6 +64,12 @@ - 元画像(RGB): `Shot{NO}.png` - sRGB 変換画像: `sRGB{CN}_{NO}.jpg`(指定した次元数ぶん生成される) +**撮影失敗時の挙動** + +- カメラからのフレーム取得に失敗した場合,**その時点で撮影全体を中止する**(残り枚数は撮影しない). +- 中止した旨をダイアログで通知する.通知は撮影終了処理(プレビュー復帰)の完了後に表示される. +- 取得済みの画像は破棄せずそのまま保存し,`Info.csv` に中止した旨と理由を記録する.失敗した1枚は成功として記録されない. + ## データ保存 (Data Saving) 撮影・キャリブレーション結果は保存フォルダ以下に日時フォルダで自動保存される. @@ -84,7 +90,8 @@ **撮影保存ファイル** -- `Info.csv`: 撮影情報(カメラ型番・SN,露光時間,ゲイン等) +- `Info.csv`: 撮影情報(カメラ型番・SN,露光時間,ゲイン等)と各枚の撮影時刻 + - 撮影ログ部の列は `Shot,Time(ms),Note`.3 列目は撮影を中止した行にのみ理由が入る - `Shot{NO}.png`: 元画像(RGB) - `sRGB{CN}_{NO}.jpg`: sRGB 変換画像 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 02ce442..7eceef7 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -101,3 +101,10 @@ - 原因: `new Bitmap(Image)` は内部で 32bppARGB への変換を伴う GDI+ DrawImage 経路を通るため遅い。プレビューは 24bppRgb(`Mat.ToBitmap()` 由来)で、同一フォーマットの生画素をそのままコピーすれば足りる。 - 方針: `LockBits` で src/dst の生画素ポインタを取得し `RtlMoveMemory`(kernel32)で行単位ネイティブコピーする `FastClone`(`/unsafe` 不要)。生成物は入力と独立した画素バッファ=所有権・スレッド安全性(Lucam クロススレッド Dispose と非衝突)は従来の `new Bitmap(bmp)` と同一で不変。src は ReadOnly ロックのみで非破壊。インデックスカラー(8bpp 以下・パレット保持要)は安全網として従来の GDI+ フルコピーにフォールバック(この経路は色は等価だが PixelFormat が 32bppArgb に変換される点に注意)。 - テスト方針(本 PLAN_03 一連の実装共通): 自動 NUnit テストで担保できるのは純ロジックの `FastClone` のみ(独立コピー・src 非破壊・ストライドパディング・Indexed フォールバック・null)。プレビューのタイマー駆動描画・`_dispBufLock` を介したクロススレッド Dispose の所有権・WM_PAINT/WM_TIMER のスケジューリングは UI スレッド/GDI/実カメラ・副ディスプレイ結合層でユニットテスト不能のため、実機 MEMMONITOR 計測(コピー時間・各段 FPS・GDI/handle・描画時間の CSV)で反復検証した。 + +- [x] **自律監査ループ(/auto-audit)の実施と撮影失敗の無通知データ破損の修正** (2026-08-03〜04): コードベース全体をバグ・脆弱性観点で巡回監査し,再現テストで正しさを証明できたものだけを自動修正(保存パスの無害化,設定値パース,資源解放,ロケール依存,ゲインのクランプ等).続けて監査で重大度「高」と判定した Lucam の撮影失敗無通知を `/implement` で修正した. + - 動機(監査): 修正の正しさを人間確認なしで担保するため,「独立した反証で誤検知を殺す → 再現テストで赤を作る → 影響範囲を特性化テストで保護する → 検証スイート全緑で初めてコミット」の 4 ゲートを課した.証明できないもの(UI・カメラ結合層,仕様判断が要るもの)は直さず報告に落とす方針. + - 設計判断(撮影失敗時): 「即中止+ダイアログ通知」をユーザーが選択.取得済み画像は破棄せず残し,`Info.csv` に中止理由を記録する(データを消すより残す方が非可逆な損失を避けられるため). + - 失敗パターンの圧縮: ① 中止通知を撮影ループ内で出す → `MessageBox` のモーダルループがメッセージをポンプするため,保存スレッドの `EnableShots(true)` がダイアログ表示中に届き,撮影終了処理(`LucamDisableFastFrames` / Sink 復帰)未完了のまま再入し得る → ② 通知を `Shot()` の戻り直後へ移動.`EndShot()` 直後ではなく `Shot()` 戻り後にしたのは,IScam が `EndShot()` 空実装で実処理を `Shot` にインライン展開しているため. + - 中止状態は `_shotAborted`(保存スレッドが読むため volatile)と中止理由(UI スレッド内で完結)に分離した.統合すると「想定外例外による中止(理由なし・通知しない)」が表現できなくなるか可視性の前提が崩れるため,統合せずコメントで意図を明示. + - 後段: 監査で自動修正しなかった指摘は `.claude/auto-audit-report.md` に集約(要人間対応).とくに撮影経路の例外境界(`btnShot*_Click` の try-catch 欠如,`BeginShot` 例外時に `CompleteAdding()` へ到達せず UI が固着する穴,想定外例外時に `EndShot()` がスキップされる件)は同根で,まとめて設計で潰すのが望ましい.