diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs
index 62a08a7..0e54a64 100644
--- a/TIASshot/Cameras/CameraBase.cs
+++ b/TIASshot/Cameras/CameraBase.cs
@@ -115,11 +115,22 @@
// 今回の撮影セッションで SaveThread を起動したか(IsSaveThreadStarted で公開する).
// 他の 3 つより早く(Shot に入る前に)落とす必要があるため BeginShotRequest でもリセットする.
private bool _saveThreadStarted = false;
+ // 今回の撮影要求の撮影間隔(ms)(単発は 0).StartSaveThread が保存キュー解放待ちの上限の算出に使う.
+ // BeginShotRequest でリセットし ShotMulti で設定する(Shot と同一の UI スレッド内で完結する).
+ private int _shotIntervalMs = 0;
+
+ // 直近に通知した校正失敗のメッセージ(同じ失敗を毎フレーム通知しないため.校正完了でクリアする).
+ // カメラのコールバックスレッド(ProcessCalibrationFrame)だけが読み書きする.
+ private string _lastCalibrationError = null;
+ // 直近の校正失敗の理由(例外メッセージ).校正完了まで,チャート検出の表示を
+ // 「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替えるために使う
+ // (FormatChartDetectionMessage)._lastCalibrationError と同じスレッドで同時に設定・クリアする.
+ private string _lastCalibrationFailureReason = null;
// SaveThread の起動完了待ちタイムアウト(通常は即座に完了する.無限待機の回避が目的)
private static readonly TimeSpan SaveThreadReadyTimeout = TimeSpan.FromSeconds(5);
- // 保存キュー解放待ちの無通信タイムアウト
- // (撮影も CompleteAdding も来ない異常時にスレッドを残さないための保険)
+ // 保存キュー解放待ちの無通信タイムアウトの下限
+ // (撮影も CompleteAdding も来ない異常時にスレッドを残さないための保険.実際の上限は GetDrainIdleTimeout)
private static readonly TimeSpan DrainIdleTimeout = TimeSpan.FromSeconds(30);
// 保存キュー解放時の 1 回あたりの取り出し待ち時間(ms)(無通信時間の判定粒度)
private const int DrainPollMs = 100;
@@ -144,6 +155,12 @@
///
public bool IsSaveThreadStarted => _saveThreadStarted && _saveThread != null && _saveThread.IsAlive;
+ ///
+ /// 画像保存スレッド(SaveThread)が生存しているか(今回のセッションかどうかは問わない).
+ /// アプリを閉じてよいかの判定(Form1.CanClose)に使う.保存中に閉じると未保存の画像が失われる.
+ ///
+ public bool IsSaving => _saveThread != null && _saveThread.IsAlive;
+
// プライベートメンバ
readonly float UpdateRate;
@@ -162,7 +179,11 @@
var tccSrgb = IoUtil.LoadMatFromCsv(Config.GetString("File/TccSrgbRef"));
var tccXyz = IoUtil.LoadMatFromCsv(Config.GetString("File/TccXyzRef"));
_colorCorrector = new ColorCorrector(tccSrgb, tccXyz);
- _chartDetector = new ChartDetector(_form.ShowMessage);
+ // チャート検出の表示は,直前の校正失敗があれば失敗の表示に差し替える(失敗を上書きして気づけなくしない).
+ // ChartDetector はカメラのコールバックスレッド(ProcessCalibrationFrame)からだけ呼ばれるため,
+ // 同じスレッドで読み書きする _lastCalibrationFailureReason をロック無しで読んでよい.
+ _chartDetector = new ChartDetector(
+ msg => _form.ShowMessage(FormatChartDetectionMessage(_lastCalibrationFailureReason, msg)));
}
///
@@ -207,7 +228,8 @@
SetInfo("データ名", _form.GetDataName());
SetInfo("撮影枚数", $"{_form.GetNumMultiShots()}");
SetInfo("撮影間隔(ms)", $"{_form.GetMultiShotsInterval()}");
- Shot(_form.GetNumMultiShots(), _form.GetMultiShotsInterval());
+ _shotIntervalMs = _form.GetMultiShotsInterval();
+ Shot(_form.GetNumMultiShots(), _shotIntervalMs);
FinishShot("Sound/MultiShotDone");
}
@@ -220,6 +242,7 @@
///
private void BeginShotRequest() {
_saveThreadStarted = false;
+ _shotIntervalMs = 0; // 単発撮影の間隔(ShotMulti は Shot の直前に設定し直す)
}
///
@@ -299,7 +322,8 @@
/// finally では未消費の Mat を解放し,撮影ボタンを必ず再有効化する(UI 固着の防止).
///
///
- protected void SaveThread(int numImages) {
+ /// 保存キュー解放待ちの無通信時間の上限(GetDrainIdleTimeout)
+ protected void SaveThread(int numImages, TimeSpan drainIdleTimeout) {
// 消費対象は起動時点の _shots に固定する(次回撮影が _shots を差し替えても影響を受けない).
var shots = _shots;
@@ -333,7 +357,7 @@
// 【重要】順序は DrainShots → ShowError → EnableShots(true) を維持すること.
// モーダル表示中は撮影ボタンが無効のままなので Shot への再入(=Join)が起きず,
// Invoke のデッドロックを避けられる.
- DrainShots(shots);
+ DrainShots(shots, drainIdleTimeout);
if (saveError != null) {
NotifySaveFailed(saveError);
}
@@ -378,13 +402,15 @@
/// 【重要】上限は「総経過時間」ではなく「無通信時間」で判定する.撮影枚数・間隔は UI から
/// 任意に設定できるため(例: 60 枚 × 1000ms),総経過時間で打ち切ると正常な長時間連写の
/// 途中で誤って待機を終え,未消費 Mat のリークと撮影中の EnableShots(true) を招く.
+ /// 無通信時間の上限は撮影間隔に応じて決める(GetDrainIdleTimeout.撮影間隔が長いと 1 枚ごとの無通信が長くなる).
///
/// 対象の保存キュー
- private void DrainShots(BlockingCollection shots) {
+ /// 無通信時間の上限
+ private void DrainShots(BlockingCollection shots, TimeSpan idleTimeout) {
var idle = Stopwatch.StartNew();
try {
while (!shots.IsCompleted) {
- if (idle.Elapsed > DrainIdleTimeout) {
+ if (idle.Elapsed > idleTimeout) {
Debug.WriteLine("警告: 保存キューの解放待機がタイムアウトしました");
break;
}
@@ -401,34 +427,108 @@
}
///
+ /// 保存キュー解放待ち(DrainShots)の無通信時間の上限を求める.
+ /// 「30 秒」と「撮影間隔の 2 倍」の大きい方とする(撮影間隔が 30 秒を超える連写でも,
+ /// 次の 1 枚を待っている間に打ち切らないため).単発撮影・間隔 0 以下は 30 秒.
+ ///
+ /// 撮影間隔(ms).単発撮影は 0
+ /// 無通信時間の上限
+ internal static TimeSpan GetDrainIdleTimeout(int intervalMs) {
+ var byInterval = TimeSpan.FromMilliseconds(2.0 * intervalMs);
+ return byInterval > DrainIdleTimeout ? byInterval : DrainIdleTimeout;
+ }
+
+ ///
/// 校正フレーム処理の共通骨格(テンプレートメソッド)
/// imgt が用意された後の校正/検出フローを担う.
/// ゲイン反映(ApplyWhiteBalance)・収束時ログ出力(LogCalibrationConverged)・
/// 校正情報記録(RecordCalibrationInfo)は派生クラスに委譲する.
/// 呼び出し順序は元コードに合わせ「LogCalibrationConverged → CalcTcc → RecordCalibrationInfo」.
+ /// カメラのコールバックから呼ばれるため,ここを例外境界とする(例外をコールバックへ伝えると
+ /// SDK のコールバックスレッドやプレビューが止まる).校正中の例外は HandleCalibrationFailure で
+ /// 通知し,チャート検出からやり直せる状態へ戻す.
///
/// 処理対象 Mat(前処理済み)
protected void ProcessCalibrationFrame(Mat imgt) {
- if (_calibrating > 0) {
- var whitePatch = Cv2.Mean(imgt, _chartMasks[12]);
- Debug.WriteLine($"White patch R {whitePatch.Val2:.00} G {whitePatch.Val1:.00} B {whitePatch.Val0:.00}");
- if (_calibrating % Config.GetInt("Calib/UpdateInterval") == 0) {
- ApplyWhiteBalance(whitePatch);
- }
- _calibrating--;
- if (_calibrating == 0) {
- LogCalibrationConverged();
- CalcTcc(imgt);
- RecordCalibrationInfo(imgt);
+ try {
+ if (_calibrating > 0) {
+ var whitePatch = Cv2.Mean(imgt, _chartMasks[12]);
+ Debug.WriteLine($"White patch R {whitePatch.Val2:.00} G {whitePatch.Val1:.00} B {whitePatch.Val0:.00}");
+ if (_calibrating % Config.GetInt("Calib/UpdateInterval") == 0) {
+ ApplyWhiteBalance(whitePatch);
+ }
+ _calibrating--;
+ if (_calibrating == 0) {
+ LogCalibrationConverged();
+ CalcTcc(imgt);
+ RecordCalibrationInfo(imgt);
#if MEMMONITOR
- PreviewProfiler.MarkEvent("CALIB_END");
+ PreviewProfiler.MarkEvent("CALIB_END");
#endif
+ }
}
+
+ if (!_calibrated && _calibrating == 0) {
+ DetectChart(imgt);
+ }
+ } catch (Exception ex) {
+ HandleCalibrationFailure(ex);
+ }
+ }
+
+ ///
+ /// 校正中の例外の後始末(ProcessCalibrationFrame の例外境界から呼ぶ).
+ /// 未校正・校正フレーム数 0・チャートマスク解放の状態へ戻し,次のフレームからチャート検出をやり直させる.
+ /// 変換行列算出の後(RecordCalibrationInfo 等)で失敗して撮影ボタンを有効化済みの場合は無効に戻す.
+ /// 通知は「校正に失敗しました(理由)」とし,直前に通知した内容と同じなら繰り返さない
+ /// (設定不備等で毎フレーム同じ例外が起きる場合に通知とログが毎フレーム出続けないため).
+ ///
+ /// 校正中に発生した例外
+ private void HandleCalibrationFailure(Exception ex) {
+ var wasCalibrated = _calibrated;
+ _calibrated = false;
+ _calibrating = 0;
+ ReleaseChartMasks();
+ if (wasCalibrated) {
+ _form.EnableShots(false);
}
- if (!_calibrated && _calibrating == 0) {
- DetectChart(imgt);
- }
+ var message = FormatCalibrationFailure(ex);
+ _lastCalibrationFailureReason = ex.Message; // 校正完了までチャート検出の表示を差し替える
+ if (!ShouldNotifyCalibrationFailure(_lastCalibrationError, message)) return;
+ _lastCalibrationError = message;
+ Debug.WriteLine($"校正に失敗しました: {ex}");
+ _form.ShowMessage(message);
+ }
+
+ /// 校正失敗の通知文(理由として例外メッセージを含める)
+ /// 校正中に発生した例外
+ internal static string FormatCalibrationFailure(Exception ex) {
+ return $"校正に失敗しました({ex.Message}).チャートの検出からやり直します";
+ }
+
+ ///
+ /// チャート検出(ChartDetector)の表示文を決める.直前の校正失敗が無ければ検出側の表示をそのまま返し,
+ /// あれば次に校正が完了するまで「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替える
+ /// (検出中の表示が毎フレーム失敗の通知を上書きし,恒常的な失敗に気づけなくなるのを防ぐ).
+ /// ただしチャートが上下逆の表示は設置し直しの案内のため差し替えない.
+ ///
+ /// 直前の校正失敗の理由(失敗が無い・校正完了後は null)
+ /// ChartDetector が出そうとした表示文
+ /// 実際に表示する文
+ internal static string FormatChartDetectionMessage(string lastFailureReason, string detectionMessage) {
+ if (lastFailureReason == null) return detectionMessage;
+ if (detectionMessage == ChartDetector.UpsideDownMessage) return detectionMessage;
+ return $"校正に失敗しました({lastFailureReason}).チャートの検出からやり直しています";
+ }
+
+ ///
+ /// 校正失敗を通知するか(直前に通知した内容と同じなら通知しない).
+ ///
+ /// 直前に通知した校正失敗の通知文(未通知・校正完了後は null)
+ /// 今回の通知文
+ internal static bool ShouldNotifyCalibrationFailure(string lastNotified, string message) {
+ return !string.Equals(lastNotified, message, StringComparison.Ordinal);
}
///
@@ -489,7 +589,8 @@
// ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする
_saveThreadReady.Reset();
- var thread = new Thread(() => SaveThread(numImages));
+ var drainIdleTimeout = GetDrainIdleTimeout(_shotIntervalMs);
+ var thread = new Thread(() => SaveThread(numImages, drainIdleTimeout));
_saveThread = thread; // 参照を保持(次回の完了待機用)
thread.Start();
// 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない)
@@ -666,6 +767,7 @@
///
protected void CalcTcc(Mat img) {
+ bool imagesSaved;
// 変換行列の計算(tccRgb・imgRois はここで所有し,使用後に確実に解放する)
using (var tccRgb = GetTccRgb(img))
using (var imgRois = GetTccRoisImage(img)) {
@@ -673,17 +775,29 @@
// データ保存
SetSaveFolder("校正");
SetInfo("校正データ", _saveFolder);
- IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccImage")), img);
- IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccRoisImage")), imgRois);
+ // 校正画像は記録用であり,保存に失敗しても変換行列(メモリ上)は有効なため校正は続ける.
+ // Cv2.ImWrite は失敗を false で返すだけなので(失敗のログは IoUtil.SaveImage が出す),
+ // 戻り値を見て完了表示で警告する.
+ var tccSaved = IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccImage")), img);
+ var roisSaved = IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccRoisImage")), imgRois);
+ imagesSaved = tccSaved && roisSaved;
// 変換行列の計算・保存は ColorCorrector に委譲する(tccRgb の所有権は渡さない)
_colorCorrector.CalcTcc(tccRgb, _saveFolder, IoUtil.SaveMatToCsv);
}
IoUtil.PlaySound(Config.GetString("Sound/CalibDone"));
- _form.ShowMessage("自動校正完了");
+ _form.ShowMessage(FormatCalibrationDone(imagesSaved));
_form.EnableShots();
_calibrated = true;
+ _lastCalibrationError = null; // 次に失敗したときは改めて通知する
+ _lastCalibrationFailureReason = null; // チャート検出の表示を従来どおりに戻す
+ }
+
+ /// 自動校正完了の表示文(校正画像の保存に失敗した場合は警告を添える)
+ /// 校正画像(TccImage・TccRoisImage)を両方とも保存できたか
+ internal static string FormatCalibrationDone(bool imagesSaved) {
+ return imagesSaved ? "自動校正完了" : "自動校正完了(校正画像の保存に失敗)";
}
///
diff --git a/TIASshot/ChartDetection/ChartDetector.cs b/TIASshot/ChartDetection/ChartDetector.cs
index eb4f2c1..3a80c03 100644
--- a/TIASshot/ChartDetection/ChartDetector.cs
+++ b/TIASshot/ChartDetection/ChartDetector.cs
@@ -25,6 +25,9 @@
// 状態表示(呼び出し側が提供)
readonly Action _showMessage;
+ /// チャートが上下逆のときの表示(設置し直しの案内.CameraBase が校正失敗後も差し替えずに出す)
+ internal const string UpsideDownMessage = "舌診チャートが上下逆方向です";
+
public ChartDetector(Action showMessage) {
_showMessage = showMessage;
// ArucoDetector は辞書を内部にコピーして保持するため,元の Dictionary はここで解放してよい
@@ -65,7 +68,7 @@
}
if (ptsPict.Count < 8) return null;
if (y40 > y41) {
- _showMessage("舌診チャートが上下逆方向です");
+ _showMessage(UpsideDownMessage);
return null;
}
diff --git a/TIASshot/LightSource.cs b/TIASshot/LightSource.cs
index 910c910..f72725d 100644
--- a/TIASshot/LightSource.cs
+++ b/TIASshot/LightSource.cs
@@ -60,6 +60,25 @@
_serial.Write(str);
}
+ ///
+ /// 光源を消してポートを閉じる(アプリ終了時に呼ぶ).開いていなければ何もしない.
+ /// 終了処理を止めないよう,消灯・クローズの失敗は捕捉してログのみ残す
+ /// (消灯に失敗してもポートは閉じる).
+ ///
+ public void Close() {
+ if (!_serial.IsOpen) return;
+ try {
+ TurnOff();
+ } catch (Exception ex) {
+ Debug.WriteLine($"光源の消灯に失敗しました: {ex}");
+ }
+ try {
+ _serial.Close();
+ } catch (Exception ex) {
+ Debug.WriteLine($"光源のポートを閉じられませんでした: {ex}");
+ }
+ }
+
public void SetLight() {
byte[] buf = new byte[] { 0x02, 0x44, 0x41, 0x58, 0x32, 0x3A, 0x44,
0x45, 0x46, 0x20, 0x33, 0x30, 0x37, 0x32, 0x03, 0x30, 0x34, 0x04 };
diff --git a/TIASshot/SelfTest/SelfTestCamera.cs b/TIASshot/SelfTest/SelfTestCamera.cs
index 062b302..3d311e0 100644
--- a/TIASshot/SelfTest/SelfTestCamera.cs
+++ b/TIASshot/SelfTest/SelfTestCamera.cs
@@ -31,6 +31,8 @@
private double _gainR = 1.0;
private Thread _previewThread;
+ // 切断時にプレビュースレッドの終了を待つ上限
+ private static readonly TimeSpan DisconnectJoinTimeout = TimeSpan.FromSeconds(3);
private volatile bool _stopPreview;
private int _frameIndex;
private int _framesDelivered;
@@ -79,12 +81,20 @@
}
///
- /// 切断.プレビュースレッドに停止を要求し,二重バッファの Bitmap を解放する.
- /// 【前提】プレビュースレッドは UI スレッドへの Invoke で待つことがあるため,
- /// 呼び出し元は UI スレッドのメッセージを回しながら IsPreviewRunning が false になるのを待ってから呼ぶこと.
+ /// 切断.プレビュースレッドに停止を要求して終了を待ち(実カメラの SDK がストリーム停止で
+ /// コールバックの終了を待つのと同じ),二重バッファの Bitmap とチャートマスクを解放する.
+ /// プレビュースレッドから UI スレッドへの呼び出し(ShowMessage / EnableShots)は待たない投稿のため,
+ /// UI スレッドから呼んでも相互待機にならない.アプリを閉じる処理(Form1_FormClosing)からも呼ばれる.
+ /// 待機には上限を設け,超えた場合はログを残して解放へ進む.
///
public override void Disconnect() {
_stopPreview = true;
+ var thread = _previewThread;
+ if (thread != null && thread != Thread.CurrentThread && thread.IsAlive) {
+ if (!thread.Join(DisconnectJoinTimeout)) {
+ Debug.WriteLine("警告: SelfTestCamera の切断でプレビュースレッドの終了待ちがタイムアウトしました");
+ }
+ }
for (var i = 0; i < _bmps.Length; i++) {
_bmps[i]?.Dispose();
_bmps[i] = null;
diff --git a/TIASshot/UI/Form1.SelfTest.cs b/TIASshot/UI/Form1.SelfTest.cs
index c4803a2..b551b23 100644
--- a/TIASshot/UI/Form1.SelfTest.cs
+++ b/TIASshot/UI/Form1.SelfTest.cs
@@ -122,6 +122,50 @@
txtMultiShotsInterval.Text = intervalMs;
}
+ // 閉じるときの後始末(ShutdownOnClose)が通過した段階の記録(古い順._selfTestLogLock で保護)
+ private readonly List _selfTestCloseSteps = new List();
+
+ ///
+ /// 閉じるときの後始末の対象にするカメラを設定する(Form1_Load の代わり.ヘッドレス Form1 は _camera を持たないため).
+ /// 閉じてよいかの判定(保存中か)と,後始末でのカメラ切断に使われる.
+ ///
+ internal void SelfTestAttachCamera(CameraBase camera) {
+ _camera = camera;
+ }
+
+ ///
+ /// ユーザーが閉じようとしたときの処理(Form1_FormClosing)を呼ぶ(UI スレッドで呼ぶこと).
+ /// ヘッドレス Form1 では FormClosing イベントが接続されていない(InitializeComponent を呼ばない)ため,この口から呼ぶ.
+ /// 閉じると決まった場合は後始末(閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源)まで行う.
+ /// フォームの破棄(Dispose)は行わない(ハーネスの後始末に任せる).
+ ///
+ /// 閉じると決まったら true,取り消されたら(保存中)false
+ internal bool SelfTestRequestClose() {
+ var e = new FormClosingEventArgs(CloseReason.UserClosing, false);
+ Form1_FormClosing(this, e);
+ return !e.Cancel;
+ }
+
+ /// 閉じ始めたか(以降 ShowMessage / EnableShots は何もしない)
+ internal bool SelfTestIsClosing => _isClosing;
+
+ /// 本モニタの再描画タイマーが動作中か(閉じるときの後始末で停止される)
+ internal bool SelfTestRefreshTimerRunning => _mainRefreshTimer.Enabled;
+
+ ///
+ /// 閉じるときの後始末が通過した段階(古い順のコピー."closing" → "timer" → "camera" → "light").
+ /// カメラ切断が例外になった場合は "camera" が抜ける.
+ ///
+ internal IReadOnlyList SelfTestCloseSteps {
+ get { lock (_selfTestLogLock) return _selfTestCloseSteps.ToArray(); }
+ }
+
+ partial void OnCloseStep(string step) {
+ lock (_selfTestLogLock) {
+ _selfTestCloseSteps.Add(step);
+ }
+ }
+
/// ShowError のヘッドレス版(モーダルダイアログを出すと無人実行が止まるため記録のみ行う)
private void SelfTestRecordError(string msg) {
Debug.WriteLine($"[SELFTEST] ShowError: {msg}");
diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs
index ba043a2..3667e62 100644
--- a/TIASshot/UI/Form1.cs
+++ b/TIASshot/UI/Form1.cs
@@ -35,6 +35,15 @@
private const int MainRefreshIntervalMs = 33; // ≒30fps(PreviewMonitor と同じ値)
private readonly System.Windows.Forms.Timer _mainRefreshTimer = new System.Windows.Forms.Timer();
+ // アプリを閉じ始めたか(Form1_FormClosing で閉じると決めた時点で立てる).
+ // 立った後は,カメラのコールバック・保存スレッド由来の UI 呼び出し(ShowMessage / EnableShots)を何もしない.
+ // 切断(SDK のストリーム停止)がコールバックの終了を待つ間に,コールバック側が UI スレッドを待つと
+ // 相互待機でハングするため.別スレッドから読むため volatile とする.
+ private volatile bool _isClosing = false;
+
+ /// 保存中に閉じようとしたときの表示
+ internal const string SavingOnCloseMessage = "保存中です.保存完了後に閉じてください";
+
///
/// コンストラクタ
@@ -121,12 +130,51 @@
///
///
private void Form1_FormClosing(object sender, FormClosingEventArgs e) {
- _mainRefreshTimer.Stop();
- if (_camera == null) return;
- _camera.Disconnect();
+ if (!CanClose(_camera != null && _camera.IsSaving)) {
+ // 保存中に閉じると未保存の画像が失われるため,閉じるのを取り消して知らせる
+ e.Cancel = true;
+ ShowMessage(SavingOnCloseMessage);
+ return;
+ }
+ ShutdownOnClose();
}
///
+ /// アプリを閉じてよいか(保存中は閉じない).
+ ///
+ /// 画像保存スレッドが動作中か(CameraBase.IsSaving)
+ /// 閉じてよければ true
+ internal static bool CanClose(bool isSaving) {
+ return !isSaving;
+ }
+
+ ///
+ /// 閉じるときの後始末(Form1_FormClosing で閉じると決めた後に呼ぶ).
+ /// 【重要】順序は「閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源を消してポートを閉じる」を守ること.
+ /// フラグを先に立てることで,切断がコールバックの終了を待つ間にコールバック側が UI 呼び出しで
+ /// UI スレッドを待つことがなくなる(ハングの防止).カメラの切断が例外になっても光源は閉じる.
+ ///
+ private void ShutdownOnClose() {
+ _isClosing = true;
+ OnCloseStep("closing");
+ _mainRefreshTimer.Stop();
+ OnCloseStep("timer");
+ try {
+ _camera?.Disconnect();
+ OnCloseStep("camera");
+ } finally {
+ _light.Close();
+ OnCloseStep("light");
+ }
+ }
+
+ ///
+ /// 閉じるときの後始末の各段階を通過したことの記録(SELFTEST でのみ実装.Release では呼び出しごと消える).
+ ///
+ /// 段階名(closing / timer / camera / light)
+ partial void OnCloseStep(string step);
+
+ ///
/// 画像撮影1枚
///
///
@@ -271,8 +319,12 @@
///
///
public void ShowMessage(string msg) {
+ // UI スレッド外(カメラのコールバック・保存スレッド)からの呼び出しは UI スレッドの処理を待たずに投稿する
+ // (BeginInvoke.撮影中で UI スレッドが塞がっていても保存が進み,画像がキューに溜まり続けないため).
+ // 閉じ始めた後・ハンドルが無い/破棄済みのときは何もしない.
+ if (!CanUpdateUi()) return;
if (InvokeRequired) {
- Invoke((MethodInvoker)delegate { ShowMessage(msg); });
+ PostToUi(delegate { ShowMessage(msg); });
return;
}
#if SELFTEST
@@ -343,8 +395,11 @@
///
///
public void EnableShots(bool enable = true) {
+ // UI スレッド外からの呼び出しは ShowMessage と同じく待たずに投稿する(同じスレッドから投稿した
+ // ShowMessage・ShowError との順序は保たれる).閉じ始めた後・ハンドルが無い/破棄済みのときは何もしない.
+ if (!CanUpdateUi()) return;
if (InvokeRequired) {
- Invoke((MethodInvoker)delegate { EnableShots(enable); });
+ PostToUi(delegate { EnableShots(enable); });
return;
}
btnShotOne.Enabled = enable;
@@ -352,6 +407,29 @@
}
///
+ /// 通知系の UI 呼び出し(ShowMessage / EnableShots)を行ってよいか.
+ /// 閉じ始めた後(_isClosing)・破棄中/破棄済み・ハンドル未生成のときは false.
+ ///
+ private bool CanUpdateUi() {
+ return !_isClosing && !IsDisposed && !Disposing && IsHandleCreated;
+ }
+
+ ///
+ /// UI スレッドへ処理を投稿する(完了を待たない).
+ /// 判定(CanUpdateUi)の直後にハンドルが破棄されると BeginInvoke が例外を投げるため,
+ /// 捕捉してログのみ残す(コールバック・保存スレッドへ例外を伝えない).
+ ///
+ /// UI スレッドで実行する処理
+ private void PostToUi(MethodInvoker action) {
+ try {
+ BeginInvoke(action);
+ } catch (InvalidOperationException ex) {
+ // ObjectDisposedException(InvalidOperationException の派生)を含む
+ Debug.WriteLine($"UI スレッドへの投稿に失敗しました(フォームの終了中): {ex.Message}");
+ }
+ }
+
+ ///
/// データフォルダを開く
///
///