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/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index 8e0eaac..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撮影を中止しました.取得済みの画像のみ保存します."); + } + + /// /// 画像保存処理 /// /// @@ -173,7 +207,13 @@ saveCount++; _form.ShowMessage($"画像{saveCount}/{numImages}枚目保存"); } - _form.ShowMessage("全ての画像保存完了"); + // 撮影が中止された場合は「全て保存完了」と表示すると誤解を与えるため, + // 取得できた枚数のみ保存した旨を表示する. + if (_shotAborted) { + _form.ShowMessage($"撮影中止:取得済みの{saveCount}枚のみ保存しました"); + } else { + _form.ShowMessage("全ての画像保存完了"); + } _form.EnableShots(true); } @@ -210,14 +250,22 @@ /// /// 撮影ループの共通骨格(テンプレートメソッド) - /// 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"); @@ -225,20 +273,21 @@ var filename = Config.GetString("File/Info"); 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; + } + + /// /// チャートの検出 /// /// 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/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/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/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()` がスキップされる件)は同根で,まとめて設計で潰すのが望ましい.