diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index d4709ad..e72186f 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -71,6 +71,23 @@ // 【前提】RunShotLoop(書き手)も ShotOne/ShotMulti の通知(読み手)も同一の UI スレッドで // 同期的に実行されるため volatile は不要.Shot をワーカースレッド化する場合は要見直し. private string _abortReason = null; + // RunShotLoop が最後まで実行され,_shots.CompleteAdding() を呼び終えたか. + // FinalizeShotSession が CompleteAdding を二重に呼ばないための判定に使う. + // (_abortReason と同じく Shot と同一スレッド内で完結するため volatile は不要) + private bool _shotLoopFinished = false; + // 今回の撮影セッションで SaveThread を起動したか(IsSaveThreadStarted で公開する). + // 書き込みも読み出しも UI スレッド(Shot と Form1 の例外境界)で完結するため volatile は不要. + private bool _saveThreadStarted = false; + + /// + /// 今回の撮影セッションで画像保存スレッド(SaveThread)を起動したか. + /// 起動済みであれば,撮影ボタンの再有効化(EnableShots(true))は SaveThread の finally が + /// 必ず行う(CompleteAdding には全経路で到達するため SaveThread は必ず完走する). + /// このとき呼び出し元(Form1 の例外境界)が先に再有効化すると,保存中に次の撮影へ再入でき, + /// UI スレッドの _saveThread.Join() と SaveThread 側の Control.Invoke が相互待機して + /// アプリがハングする.そのため呼び出し元は本プロパティが false の場合のみ再有効化すること. + /// + public bool IsSaveThreadStarted => _saveThreadStarted; // プライベートメンバ readonly float UpdateRate; @@ -192,32 +209,83 @@ } /// - /// 画像保存スレッド処理 + /// 画像保存スレッド処理. + /// バックグラウンドスレッドの未処理例外は .NET Framework 4.8 ではプロセスを即終了させるため, + /// ここに例外境界を置き,保存失敗(ディスク満杯・パス不正・OpenCV 例外等)でもアプリを継続させる. + /// ユーザー通知は Form1(ShowError)へ委譲し,本クラスからは MessageBox を直接呼ばない. + /// finally では未消費の Mat を解放し,撮影ボタンを必ず再有効化する(UI 固着の防止). /// /// protected void SaveThread(int numImages) { + // 消費対象は起動時点の _shots に固定する(次回撮影が _shots を差し替えても影響を受けない). + var shots = _shots; + // スレッド起動完了を通知(producer が待機している) _saveThreadReady.Set(); int saveCount = 0; - foreach (var img in _shots.GetConsumingEnumerable()) { - using (img) { - SaveImages(img, saveCount); + string saveError = null; + try { + foreach (var img in shots.GetConsumingEnumerable()) { + using (img) { + SaveImages(img, saveCount); + } + saveCount++; + _form.ShowMessage($"画像{saveCount}/{numImages}枚目保存"); } - saveCount++; - _form.ShowMessage($"画像{saveCount}/{numImages}枚目保存"); + // 撮影が中止された場合は「全て保存完了」と表示すると誤解を与えるため, + // 取得できた枚数のみ保存した旨を表示する. + if (_shotAborted) { + _form.ShowMessage($"撮影中止:取得済みの{saveCount}枚のみ保存しました"); + } else { + _form.ShowMessage("全ての画像保存完了"); + } + } catch (Exception ex) { + // 撮影自体の失敗(Form1 の例外境界が通知する)とは別事象のため,保存失敗として通知する. + // モーダル通知(ShowError)は UI スレッドへの Invoke でブロックするため,未消費 Mat の + // 解放を先に済ませるべく,ここではメッセージを保持するだけにして finally で通知する. + Debug.WriteLine($"画像保存に失敗しました({saveCount + 1}枚目): {ex}"); + saveError = ex.Message; + } finally { + DrainShots(shots); + if (saveError != null) { + _form.ShowError($"画像の保存に失敗しました.\r\n{saveError}"); + } + // 撮影ボタンの再有効化は本スレッドの責務(Form1 の例外境界は再有効化しない). + _form.EnableShots(true); } - // 撮影が中止された場合は「全て保存完了」と表示すると誤解を与えるため, - // 取得できた枚数のみ保存した旨を表示する. - if (_shotAborted) { - _form.ShowMessage($"撮影中止:取得済みの{saveCount}枚のみ保存しました"); - } else { - _form.ShowMessage("全ての画像保存完了"); - } - _form.EnableShots(true); } /// + /// 保存キューに残った Mat を取り出して解放する(例外で保存ループを抜けた場合のリーク防止). + /// producer が撮影を続けている可能性があるため,CompleteAdding 済みかつ空になるまで待つ. + /// CompleteAdding には全経路で到達するが(RunShotLoop / FinalizeShotSession), + /// 万一到達しない異常時にスレッドが残らないよう待機時間の上限を設ける. + /// + /// 対象の保存キュー + private void DrainShots(BlockingCollection shots) { + var deadline = Stopwatch.StartNew(); + try { + while (!shots.IsCompleted) { + if (deadline.Elapsed > DrainTimeout) { + Debug.WriteLine("警告: 保存キューの解放待機がタイムアウトしました"); + break; + } + if (shots.TryTake(out var remaining, 100)) { + remaining?.Dispose(); + } + } + } catch (Exception ex) { + // 解放処理自体の失敗はユーザー操作で回復できないため,ログのみ残して継続する + // (ここで throw すると EnableShots(true) に到達できず UI が固着する). + Debug.WriteLine($"保存キューの解放に失敗しました: {ex}"); + } + } + + // 保存キュー解放待ちの上限(CompleteAdding が来ない異常時にスレッドを残さないための保険) + private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(30); + + /// /// 校正フレーム処理の共通骨格(テンプレートメソッド) /// imgt が用意された後の校正/検出フローを担う. /// ゲイン反映(ApplyWhiteBalance)・収束時ログ出力(LogCalibrationConverged)・ @@ -249,6 +317,42 @@ } /// + /// 撮影セッションの開始準備(派生クラスの Shot で,SaveThread 起動より前に呼ぶこと). + /// 中止状態と「撮影ループを完走したか」のフラグを初期化する. + /// RunShotLoop に到達しないまま例外で抜けた場合を FinalizeShotSession が検出できるようにする. + /// + protected void PrepareShotSession() { + _shotLoopFinished = false; + _saveThreadStarted = false; + _shotAborted = false; + _abortReason = null; + } + + /// + /// SaveThread の起動を記録する(派生クラスの Shot で Thread.Start() の直後に呼ぶこと). + /// 以降は EnableShots(true) の責任が SaveThread に移る(IsSaveThreadStarted 参照). + /// + protected void MarkSaveThreadStarted() { + _saveThreadStarted = true; + } + + /// + /// 撮影セッションの終端処理(派生クラスの Shot の finally で,撮影終了処理の後に呼ぶこと). + /// RunShotLoop に到達する前に例外が発生すると _shots.CompleteAdding() が呼ばれず, + /// SaveThread が GetConsumingEnumerable で永久にブロックしてスレッドがリークし, + /// 撮影ボタンを再有効化する EnableShots(true) にも到達しなくなる. + /// これを防ぐため,撮影ループ未完走の場合に限り中止扱いにして CompleteAdding を呼ぶ. + /// (完走時は RunShotLoop の finally が呼び済みのため,ここでは呼ばない) + /// + protected void FinalizeShotSession() { + if (_shotLoopFinished) return; // RunShotLoop が CompleteAdding 済み(二重呼び出し防止) + // 撮影枚数が揃っていないため中止扱いとし,SaveThread に「全て保存完了」と表示させない. + // 中止理由は設定しない(想定外例外は呼び出し元 Form1 の例外境界が通知する). + _shotAborted = true; + _shots.CompleteAdding(); + } + + /// /// 撮影ループの共通骨格(テンプレートメソッド) /// 撮影ログ(Info.csv)の準備と中止状態の管理を担い,ループ本体は CaptureShots に, /// フレーム取得(CaptureFrame)は派生クラスに委譲する. @@ -289,6 +393,8 @@ // 中止理由は EndShot 完了後(ShotOne/ShotMulti)に通知するため保持する. _abortReason = abortReason; _shots.CompleteAdding(); + // CompleteAdding 済みであることを記録し,FinalizeShotSession の二重呼び出しを防ぐ. + _shotLoopFinished = true; #if MEMMONITOR PreviewProfiler.MarkEvent("SHOT_END"); #endif diff --git a/TIASshot/Cameras/IScam.cs b/TIASshot/Cameras/IScam.cs index 9e125f2..26458eb 100644 --- a/TIASshot/Cameras/IScam.cs +++ b/TIASshot/Cameras/IScam.cs @@ -170,45 +170,66 @@ /// /// protected override void Shot(int numImages = 1, int interval = 0) { - _ic.LiveStop(); - _ic.Sink = _snapSink; - _ic.LiveStart(); - _snapSinkRef = _ic.Sink as FrameSnapSink; - // 前回の SaveThread が完了するまで待機(低スペックPCでの競合回避) + // 【重要】撮影セッションの初期化(PrepareShotSession)は必ずこの待機の後に行う. + // 先に _shotAborted をクリアすると,前回が中止で終わった直後の撮影で,まだ走っている + // 前回の SaveThread が中止フラグを false と読み,「全ての画像保存完了」と誤表示する. if (_saveThread != null && _saveThread.IsAlive) { _saveThread.Join(); } - // 古い BlockingCollection を破棄してから新しいインスタンスを作成 - _shots?.Dispose(); - _shots = new BlockingCollection(); - _snapSinkRef.SnapSingle(TimeSpan.FromSeconds(5)); // 最初のフレームを捨てる + PrepareShotSession(); - // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする - _saveThreadReady.Reset(); + // Sink 切り替え以降は,どの経路でも撮影終了処理(EndShot)と CompleteAdding に到達させる. + // 捨てフレームの SnapSingle がタイムアウトする等で例外が出ると,Sink が _snapSink のままとなり + // ライブプレビューが停止したままになり,また SaveThread が終了通知を受け取れず永久にブロックする. + try { + BeginShot(); - var thread = new Thread(() => SaveThread(numImages)); - _saveThread = thread; // 参照を保持(次回の完了待機用) - thread.Start(); + // 新しいインスタンスを作成してから古い BlockingCollection を破棄する + // (順序を逆にすると,破棄と生成の間に例外が発生した場合に _shots が破棄済みインスタンスを + // 指したままとなり,後段の CompleteAdding が ObjectDisposedException になる) + var previousShots = _shots; + _shots = new BlockingCollection(); + previousShots?.Dispose(); - // SaveThread が起動完了するまで待機(consumer 準備完了を保証) - // タイムアウト設定で無限待機を回避(通常は即座に完了するはず) - if (!_saveThreadReady.Wait(TimeSpan.FromSeconds(5))) { - Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); + _snapSinkRef.SnapSingle(TimeSpan.FromSeconds(5)); // 最初のフレームを捨てる + + // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする + _saveThreadReady.Reset(); + + var thread = new Thread(() => SaveThread(numImages)); + _saveThread = thread; // 参照を保持(次回の完了待機用) + thread.Start(); + // 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない) + MarkSaveThreadStarted(); + + // SaveThread が起動完了するまで待機(consumer 準備完了を保証) + // タイムアウト設定で無限待機を回避(通常は即座に完了するはず) + if (!_saveThreadReady.Wait(TimeSpan.FromSeconds(5))) { + Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); + } + + RunShotLoop(numImages, interval); + } finally { + // EndShot が失敗しても SaveThread を必ず終了させる(順序: カメラ状態復帰 → 終了通知) + try { + EndShot(); + } finally { + FinalizeShotSession(); + } } - - RunShotLoop(numImages, interval); - - _ic.LiveStop(); - _ic.Sink = _queueSink; - _ic.LiveStart(); } /// - /// 撮影開始処理(IScam 固有): Shot 内でインライン実行済みのため空実装 + /// 撮影開始処理(IScam 固有): 撮影用の Sink(FrameSnapSink)へ切り替える /// - protected override void BeginShot() { } + protected override void BeginShot() { + _ic.LiveStop(); + _ic.Sink = _snapSink; + _ic.LiveStart(); + _snapSinkRef = _ic.Sink as FrameSnapSink; + } /// /// 1 フレーム取得(IScam 固有) @@ -224,9 +245,14 @@ } /// - /// 撮影終了処理(IScam 固有): Shot 内でインライン実行済みのため空実装 + /// 撮影終了処理(IScam 固有): ライブプレビュー用の Sink(_queueSink)へ戻す. + /// 例外経路でも必ず実行し,プレビューが停止したままになるのを防ぐ. /// - protected override void EndShot() { } + protected override void EndShot() { + _ic.LiveStop(); + _ic.Sink = _queueSink; + _ic.LiveStart(); + } #if WHITEBOARD // --------------------------------------------------------------- diff --git a/TIASshot/Cameras/Lucam.cs b/TIASshot/Cameras/Lucam.cs index 21248d0..93f65d8 100644 --- a/TIASshot/Cameras/Lucam.cs +++ b/TIASshot/Cameras/Lucam.cs @@ -229,9 +229,14 @@ _saveThread.Join(); } - // 古い BlockingCollection を破棄してから新しいインスタンスを作成 - _shots?.Dispose(); + PrepareShotSession(); + + // 新しいインスタンスを作成してから古い BlockingCollection を破棄する + // (順序を逆にすると,破棄と生成の間に例外が発生した場合に _shots が破棄済みインスタンスを + // 指したままとなり,後段の CompleteAdding が ObjectDisposedException になる) + var previousShots = _shots; _shots = new BlockingCollection(); + previousShots?.Dispose(); // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする _saveThreadReady.Reset(); @@ -239,15 +244,30 @@ var thread = new Thread(() => SaveThread(numImages)); _saveThread = thread; // 参照を保持(次回の完了待機用) thread.Start(); + // 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない) + MarkSaveThreadStarted(); - // SaveThread が起動完了するまで待機(consumer 準備完了を保証) - if (!_saveThreadReady.Wait(TimeSpan.FromSeconds(5))) { - Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); + // SaveThread 起動後は,どの経路でも撮影終了処理(EndShot)と CompleteAdding に到達させる. + // BeginShot(LucamEnableFastFrames は失敗しても false を返すだけで例外を投げないが, + // 直後のバッファ確保 new byte[...] は OutOfMemoryException を投げ得る)等で例外が出ると, + // EndShot 未実行のまま LucamDisableFastFrames が呼ばれず高速フレームモードが残り, + // また SaveThread が終了通知を受け取れず永久にブロックしてしまう. + try { + // SaveThread が起動完了するまで待機(consumer 準備完了を保証) + if (!_saveThreadReady.Wait(TimeSpan.FromSeconds(5))) { + Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); + } + + BeginShot(); + RunShotLoop(numImages, interval); + } finally { + // EndShot が失敗しても SaveThread を必ず終了させる(順序: カメラ状態復帰 → 終了通知) + try { + EndShot(); + } finally { + FinalizeShotSession(); + } } - - BeginShot(); - RunShotLoop(numImages, interval); - EndShot(); } /// diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index 01d811c..5a169ee 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -132,8 +132,7 @@ /// /// private void btnShot1_Click(object sender, EventArgs e) { - EnableShots(false); - _camera.ShotOne(); + ExecuteShot(() => _camera.ShotOne()); } /// @@ -142,8 +141,35 @@ /// /// private void btnShotMulti_Click(object sender, EventArgs e) { + ExecuteShot(() => _camera.ShotMulti()); + } + + /// + /// 撮影の例外境界.撮影ボタンを無効化して撮影処理を実行し, + /// 想定外の例外が発生した場合はユーザーに通知する. + /// 例外を捕捉しないと WinForms の未処理例外ダイアログになるうえ, + /// SaveThread を起動する前に失敗した場合は EnableShots(true) に誰も到達せず, + /// 撮影ボタンが無効のまま固着してアプリの再起動が必要になる. + /// 【重要】SaveThread 起動後(IsSaveThreadStarted == true)は再有効化してはならない. + /// 撮影が例外で終わっても保存はまだ続いており,ここで先に有効化すると保存中に次の撮影へ + /// 再入できてしまい,UI スレッドの _saveThread.Join() と SaveThread 側の Control.Invoke が + /// 相互待機してアプリがハングする.この経路の再有効化は SaveThread の finally が必ず行う + /// (CompleteAdding には全経路で到達するため SaveThread は必ず完走する). + /// 撮影の中止通知(CaptureFailedException 由来)は例外ではなく CameraBase 側が + /// 撮影終了処理の完了後に行うため,ここでは扱わない. + /// + /// 撮影処理(ShotOne / ShotMulti) + private void ExecuteShot(Action shot) { EnableShots(false); - _camera.ShotMulti(); + try { + shot(); + } catch (Exception ex) { + Debug.WriteLine($"撮影に失敗しました: {ex}"); + // カメラの状態復帰(EndShot)と SaveThread の終了通知は Shot 内の finally で完了済み. + // SaveThread 未起動の経路だけをここで復帰させる(起動済みなら SaveThread に任せる). + if (_camera == null || !_camera.IsSaveThreadStarted) EnableShots(true); + ShowError($"撮影に失敗しました.\r\n{ex.Message}"); + } } ///