Newer
Older
TIASShot / TIASshot / Cameras / CameraBase.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Eventing.Reader;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Runtime.Remoting.Channels;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Forms;
using System.Media;
using OpenCvSharp;

namespace TIASshot {
    internal abstract class CameraBase {
        // インターフェース
        public abstract bool Connect();
        public abstract void Disconnect();

        /// <summary>
        /// 撮影セッション本体(派生クラスで実装する).
        /// カメラ固有の準備を除き,基底クラスのライフサイクルメソッドを次の順序で必ず呼ぶこと.
        /// 順序を崩すと,撮影ボタンの固着・SaveThread のリーク・保存完了メッセージの誤表示が起きる.
        ///
        /// <code>
        /// WaitPreviousSaveThread();       // 前回の保存完了を待つ(この前にセッションを初期化しない)
        /// PrepareShotSession();           // 状態フラグの初期化
        /// ResetShotQueue();               // 保存キューの作り直し
        /// StartSaveThread(numImages);     // SaveThread 起動(以降 EnableShots(true) は SaveThread の責務)
        /// try {
        ///     WaitSaveThreadReady();      // consumer の準備完了を待つ
        ///     BeginShot();                // カメラ固有の撮影開始処理
        ///     RunShotLoop(numImages, interval);
        /// } finally {
        ///     EndShotSession();           // EndShot → FinalizeShotSession(順序厳守)
        /// }
        /// </code>
        ///
        /// カメラ固有の事情で ResetShotQueue / StartSaveThread / BeginShot の位置を前後させる場合でも,
        /// 「PrepareShotSession より後」「StartSaveThread 以降は必ず EndShotSession に到達する」の
        /// 2 点は守ること(IScam は Sink 切り替えの都合で BeginShot を try の内側で先に呼ぶ).
        /// </summary>
        /// <param name="numImages">撮影枚数</param>
        /// <param name="interval">撮影間隔(ms)</param>
        protected abstract void Shot(int numImages=1, int interval=0);

#if WHITEBOARD
        /// <summary>
        /// 白板撮影(WHITEBOARD 限定).IScam でのみ実装.
        /// 未対応の派生クラス(Lucam 等)はこのデフォルト実装でユーザーに通知して何もしない.
        /// </summary>
        /// <param name="numImages">撮影枚数</param>
        public virtual void ShotWhiteBoard(int numImages) {
            _form.ShowMessage("白板撮影は ImagingSource カメラのみ対応しています");
        }
#endif

        // テンプレートメソッド用抽象メソッド(プレビュー/校正処理)
        protected abstract void ApplyWhiteBalance(Scalar whitePatch);
        protected abstract void LogCalibrationConverged();
        protected abstract void RecordCalibrationInfo(Mat imgt);

        // テンプレートメソッド用抽象メソッド(撮影処理)
        protected abstract void BeginShot();
        protected abstract Mat CaptureFrame();
        protected abstract void EndShot();

        // プロパティ
        public string DeviceName { get; protected set; } = "Unknown";
        public string SerialNumber { get; protected set; }
        public string ErrorMsg { get; protected set; }
        public bool IsCalibrating => _calibrating > 0;

        // 派生クラスで使用するメンバ
        protected Form1 _form;
        protected int _calibrating = 0;
        protected bool _calibrated = false;
        protected Bitmap[] _bmps = new Bitmap[2];
        protected int _bmpIndex = 0;
        protected bool _isPreview = false;
        protected List<Mat> _chartMasks = new List<Mat>();
        protected string _saveFolder = "";
        protected BlockingCollection<Mat> _shots = new BlockingCollection<Mat>();
        protected Dictionary<string, string> _ShotInfo = new Dictionary<string, string>(); // 設定値保存用
        protected Rect _roi;
        protected Thread _saveThread = null;  // SaveThreadへの参照を保持(前回完了待機用)
        protected ManualResetEventSlim _saveThreadReady = new ManualResetEventSlim(false);  // SaveThread起動完了の同期

        // --- 撮影セッションの状態フラグ ---------------------------------------------------
        // 4 つのフラグは「役割・スレッド可視性の前提・リセット地点」がそれぞれ異なるため統合しない.
        //
        // | フラグ             | 書き手 → 読み手              | 可視性       | リセット地点        |
        // | ------------------ | ---------------------------- | ------------ | ------------------- |
        // | _shotAborted       | RunShotLoop → SaveThread     | volatile 必須 | PrepareShotSession  |
        // | _abortReason       | RunShotLoop → ShotOne/Multi  | 同一スレッド | PrepareShotSession / 通知時 |
        // | _shotLoopFinished  | RunShotLoop → FinalizeShotSession | 同一スレッド | PrepareShotSession  |
        // | _saveThreadStarted | Shot → Form1 の例外境界      | 同一スレッド | BeginShotRequest / PrepareShotSession |
        //
        // 【重要】_shotAborted 以外は「Shot と同一の UI スレッド内で完結する」ことが前提である.
        // Shot をワーカースレッド化する場合は可視性の前提から見直すこと.

        // 撮影が途中で中止されたか.撮影スレッド(RunShotLoop)が書き,別スレッドの SaveThread が
        // 読むため volatile とする(他のフラグとは可視性の前提が異なるため統合しない).
        protected volatile bool _shotAborted = false;
        // 中止理由(EndShot 完了後にユーザー通知するため保持する.通知時にクリアする).
        private string _abortReason = null;
        // RunShotLoop が最後まで実行され,_shots.CompleteAdding() を呼び終えたか.
        // FinalizeShotSession が CompleteAdding を二重に呼ばないための判定に使う.
        private bool _shotLoopFinished = false;
        // 今回の撮影セッションで 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 も来ない異常時にスレッドを残さないための保険.実際の上限は GetDrainIdleTimeout)
        private static readonly TimeSpan DrainIdleTimeout = TimeSpan.FromSeconds(30);
        // 保存キュー解放時の 1 回あたりの取り出し待ち時間(ms)(無通信時間の判定粒度)
        private const int DrainPollMs = 100;

        /// <summary>
        /// 画像保存スレッド(SaveThread)が起動済みで,かつまだ生存しているか.
        /// true であれば,撮影ボタンの再有効化(EnableShots(true))は SaveThread の finally が
        /// 必ず行う(CompleteAdding には全経路で到達するため SaveThread は必ず完走する).
        /// このとき呼び出し元(Form1 の例外境界)が先に再有効化すると,保存中に次の撮影へ再入でき,
        /// UI スレッドの _saveThread.Join() と SaveThread 側の Control.Invoke が相互待機して
        /// アプリがハングする.そのため呼び出し元は本プロパティが false の場合のみ再有効化すること.
        /// 【重要】フラグ単独では判定しない.フラグのリセットは Shot 内(PrepareShotSession)で行うため,
        /// Shot に入る前(SetSaveFolder / SetSnapParam / 前回 SaveThread の Join)で例外が出ると
        /// 前回セッションの true が残り,誰も再有効化しないまま撮影ボタンが固着する.
        /// 生存判定を併用すれば,既に終了した SaveThread は再有効化済みのため,
        /// 呼び出し元が重ねて EnableShots(true) を呼んでも無害である.
        /// 【前提条件】撮影ボタンは画像の保存完了まで有効化されないこと(撮影中〜保存中は無効のまま).
        /// この前提が崩れる変更(保存を待たずに有効化する,撮影中に再校正等から Shot へ入れる)を加えると,
        /// 前回の SaveThread が生存中に次の Shot へ入れてしまい,UI スレッドの _saveThread.Join() と
        /// SaveThread 側の Control.Invoke が相互待機してアプリがハングする.
        /// 本プロパティによる判定だけでは防げないため,その場合は撮影の再入制御そのものを設計し直すこと.
        /// </summary>
        public bool IsSaveThreadStarted => _saveThreadStarted && _saveThread != null && _saveThread.IsAlive;

        /// <summary>
        /// 画像保存スレッド(SaveThread)が生存しているか(今回のセッションかどうかは問わない).
        /// アプリを閉じてよいかの判定(Form1.CanClose)に使う.保存中に閉じると未保存の画像が失われる.
        /// </summary>
        public bool IsSaving => _saveThread != null && _saveThread.IsAlive;

        // プライベートメンバ
        readonly float UpdateRate;

        // 色補正処理(TCC 変換行列の計算・色変換)
        readonly ColorCorrector _colorCorrector;

        // チャート検出処理(ARマーカー検出・マスク生成)
        readonly ChartDetector _chartDetector;

        /// <summary>
        /// カメラの基本クラス
        /// </summary>
        public CameraBase(Form1 form) {
            _form = form;
            UpdateRate = Config.GetFloat("Calib/UpdateRate");
            var tccSrgb = IoUtil.LoadMatFromCsv(Config.GetString("File/TccSrgbRef"));
            var tccXyz = IoUtil.LoadMatFromCsv(Config.GetString("File/TccXyzRef"));
            _colorCorrector = new ColorCorrector(tccSrgb, tccXyz);
            // チャート検出の表示は,直前の校正失敗があれば失敗の表示に差し替える(失敗を上書きして気づけなくしない).
            // ChartDetector はカメラのコールバックスレッド(ProcessCalibrationFrame)からだけ呼ばれるため,
            // 同じスレッドで読み書きする _lastCalibrationFailureReason をロック無しで読んでよい.
            _chartDetector = new ChartDetector(
                msg => _form.ShowMessage(FormatChartDetectionMessage(_lastCalibrationFailureReason, msg)));
        }

        /// <summary>
        /// 起動チェック
        /// </summary>
        /// <returns></returns>
        protected bool BootCheck() {

            if (!Config.IsLoaded()) {
                ErrorMsg = "設定ファイル(Config.xml)の読み込みに失敗しました.\r\n終了します.";
                return false;
            }
            if (!_colorCorrector.SrgbRefLoaded) {
                ErrorMsg = $"ファイル({Config.GetString("File/TccSrgbRef")})の読み込みに失敗しました.\r\n終了します.";
                return false;
            }
            if (!_colorCorrector.XyzRefLoaded) {
                ErrorMsg = $"ファイル({Config.GetString("File/TccXyzRef")})の読み込みに失敗しました.\r\n終了します.";
                return false;
            }
            return true;
        }

        /// <summary>
        /// 画像撮影1枚
        /// </summary>
        public void ShotOne() {
            BeginShotRequest();
            SetSaveFolder(_form.GetDataName());
            SetInfo("データ名", _form.GetDataName());
            SetInfo("撮影枚数", "1");
            Shot();
            FinishShot("Sound/OneShotDone");
        }

        /// <summary>
        /// 複数画像撮影
        /// </summary>
        public void ShotMulti() {
            BeginShotRequest();
            SetSaveFolder(_form.GetDataName());
            SetInfo("データ名", _form.GetDataName());
            SetInfo("撮影枚数", $"{_form.GetNumMultiShots()}");
            SetInfo("撮影間隔(ms)", $"{_form.GetMultiShotsInterval()}");
            _shotIntervalMs = _form.GetMultiShotsInterval();
            Shot(_form.GetNumMultiShots(), _shotIntervalMs);
            FinishShot("Sound/MultiShotDone");
        }

        /// <summary>
        /// 撮影要求の受け付け開始(ShotOne / ShotMulti の先頭で,他の何よりも先に呼ぶこと).
        /// 「今回のセッションで SaveThread を起動した」フラグを落とし,前回セッションの値が
        /// Shot 到達前の例外(SetSaveFolder のフォルダ作成失敗等)に持ち越されないようにする.
        /// _shotAborted 等は前回の SaveThread がまだ読み得るためここではクリアせず,
        /// 前回 SaveThread の Join 後(PrepareShotSession)に行う.
        /// </summary>
        private void BeginShotRequest() {
            _saveThreadStarted = false;
            _shotIntervalMs = 0;    // 単発撮影の間隔(ShotMulti は Shot の直前に設定し直す)
        }

        /// <summary>
        /// 撮影完了後の後処理(Shot から戻った直後に呼ぶこと).
        /// 正常終了時は完了音を鳴らして「撮影終了」を表示する.
        /// 中止時は完了音・完了メッセージを出さず(SaveThread が中止を表示する),中止理由を通知する.
        /// </summary>
        /// <param name="soundConfigKey">完了音の Config キー</param>
        private void FinishShot(string soundConfigKey) {
            if (_shotAborted) {
                NotifyShotAborted();
                return;
            }
            IoUtil.PlaySound(Config.GetString(soundConfigKey));
            _form.ShowMessage("撮影終了");
        }

        /// <summary>
        /// 撮影中止をユーザーへ通知する(撮影終了処理 EndShot の完了後に呼ぶこと).
        /// MessageBox はモーダルループ内でメッセージをポンプするため,カメラの状態遷移
        /// (Lucam: LucamDisableFastFrames / IScam: LiveStop+Sink 復帰)が終わる前に表示すると,
        /// 撮影ボタン再有効化と相まって Shot がネスト実行され得る.
        /// 通知後に理由をクリアし,二重通知を防ぐ.
        /// </summary>
        private void NotifyShotAborted() {
            var reason = _abortReason;
            _abortReason = null;
            if (reason == null) return;     // 想定外例外による中止(理由なし)は通知対象外
            _form.ShowError($"{reason}\r\n撮影を中止しました.取得済みの画像のみ保存します.");
        }

        /// <summary>
        /// 画像保存処理
        /// </summary>
        /// <param name="img"></param>
        /// <param name="idx"></param>
        protected void SaveImages(Mat img, int idx) {

            var filename = Config.GetString("File/RgbImage");
            filename = filename.Replace("{NO}", $"{idx + 1:0000}");
            SaveImageOrThrow(IoUtil.CombineSaveFilePath(_saveFolder, filename), img);

            foreach (var channel in _colorCorrector.Channels) {
                // ConvertImage は src を in-place で CV_64FC3 に変換するため、
                // 各チャンネルで独立したクローンを作成して元画像を保護する。
                // 【重要】Clone を作成しないと、同一 Mat を複数チャンネルで変換時に
                // 2チャンネル目以降が破壊された型(CV_64FC3)を処理してクラッシュする。
                using (var imgClone = img.Clone())
                using (var converted = _colorCorrector.ConvertImage(imgClone, channel)) {
                    filename = GetFilenameWithChannel("File/SrgbImage", channel);
                    filename = filename.Replace("{NO}", $"{idx + 1:0000}");
                    SaveImageOrThrow(IoUtil.CombineSaveFilePath(_saveFolder, filename), converted);
                }
            }
        }

        /// <summary>
        /// 撮影画像を保存し,失敗(IoUtil.SaveImage が false)なら IOException を投げる(SaveImages 専用).
        /// Cv2.ImWrite は保存失敗(ディスク満杯・権限・同名フォルダ等)で例外を出さず false を返すだけのため,
        /// 戻り値を無視すると保存できていない画像が「全ての画像保存完了」と表示される.
        /// 例外にして SaveThread の catch → finally(DrainShots → NotifySaveFailed → RestoreShotButtons)の
        /// 失敗経路に乗せ,ユーザーへ保存失敗を通知する.
        /// </summary>
        /// <param name="path">保存先ファイルパス</param>
        /// <param name="img">保存する画像</param>
        private static void SaveImageOrThrow(string path, Mat img) {
            if (!IoUtil.SaveImage(path, img)) {
                throw new IOException($"ファイル({path})の書き込みに失敗しました.");
            }
        }

        /// <summary>
        /// 画像保存スレッド処理.
        /// バックグラウンドスレッドの未処理例外は .NET Framework 4.8 ではプロセスを即終了させるため,
        /// ここに例外境界を置き,保存失敗(ディスク満杯・パス不正・OpenCV 例外等)でもアプリを継続させる.
        /// ユーザー通知は Form1(ShowError)へ委譲し,本クラスからは MessageBox を直接呼ばない.
        /// finally では未消費の Mat を解放し,撮影ボタンを必ず再有効化する(UI 固着の防止).
        /// </summary>
        /// <param name="numImages"></param>
        /// <param name="drainIdleTimeout">保存キュー解放待ちの無通信時間の上限(GetDrainIdleTimeout)</param>
        protected void SaveThread(int numImages, TimeSpan drainIdleTimeout) {
            // 消費対象は起動時点の _shots に固定する(次回撮影が _shots を差し替えても影響を受けない).
            var shots = _shots;

            // スレッド起動完了を通知(producer が待機している)
            _saveThreadReady.Set();

            int saveCount = 0;
            string saveError = null;
            try {
                foreach (var img in shots.GetConsumingEnumerable()) {
                    using (img) {
                        SaveImages(img, saveCount);
                    }
                    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 → ShowError → EnableShots(true) を維持すること.
                // モーダル表示中は撮影ボタンが無効のままなので Shot への再入(=Join)が起きず,
                // Invoke のデッドロックを避けられる.
                DrainShots(shots, drainIdleTimeout);
                if (saveError != null) {
                    NotifySaveFailed(saveError);
                }
                RestoreShotButtons();
            }
        }

        /// <summary>
        /// 保存失敗をユーザーへ通知する(SaveThread の finally から呼ぶ).
        /// フォーム破棄後(アプリ終了時に保存が残っている場合)の Control.Invoke は
        /// ObjectDisposedException / InvalidOperationException を投げる.finally から例外が出ると
        /// バックグラウンドスレッドの未処理例外となりプロセスが即終了するため個別に捕捉する.
        /// </summary>
        /// <param name="saveError">保存失敗の理由(例外メッセージ)</param>
        private void NotifySaveFailed(string saveError) {
            try {
                _form.ShowError($"画像の保存に失敗しました.\r\n{saveError}");
            } catch (Exception ex) {
                Debug.WriteLine($"保存失敗の通知に失敗しました: {ex}");
            }
        }

        /// <summary>
        /// 撮影ボタンを再有効化する(SaveThread の finally から呼ぶ).
        /// 再有効化は SaveThread 起動後の全経路で本スレッドの責務であり,
        /// Form1 の例外境界は再有効化しない(IsSaveThreadStarted 参照).
        /// NotifySaveFailed と同じ理由で,Invoke の失敗はログのみに留める.
        /// </summary>
        private void RestoreShotButtons() {
            try {
                _form.EnableShots(true);
            } catch (Exception ex) {
                Debug.WriteLine($"撮影ボタンの再有効化に失敗しました: {ex}");
            }
        }

        /// <summary>
        /// 保存キューに残った Mat を取り出して解放する(例外で保存ループを抜けた場合のリーク防止).
        /// producer が撮影を続けている可能性があるため,CompleteAdding 済みかつ空になるまで待つ.
        /// CompleteAdding には全経路で到達するが(RunShotLoop / FinalizeShotSession),
        /// 万一到達しない異常時にスレッドが残らないよう待機時間の上限を設ける.
        /// 【重要】上限は「総経過時間」ではなく「無通信時間」で判定する.撮影枚数・間隔は UI から
        /// 任意に設定できるため(例: 60 枚 × 1000ms),総経過時間で打ち切ると正常な長時間連写の
        /// 途中で誤って待機を終え,未消費 Mat のリークと撮影中の EnableShots(true) を招く.
        /// 無通信時間の上限は撮影間隔に応じて決める(GetDrainIdleTimeout.撮影間隔が長いと 1 枚ごとの無通信が長くなる).
        /// </summary>
        /// <param name="shots">対象の保存キュー</param>
        /// <param name="idleTimeout">無通信時間の上限</param>
        private void DrainShots(BlockingCollection<Mat> shots, TimeSpan idleTimeout) {
            var idle = Stopwatch.StartNew();
            try {
                while (!shots.IsCompleted) {
                    if (idle.Elapsed > idleTimeout) {
                        Debug.WriteLine("警告: 保存キューの解放待機がタイムアウトしました");
                        break;
                    }
                    if (shots.TryTake(out var remaining, DrainPollMs)) {
                        remaining?.Dispose();
                        idle.Restart();     // producer が生きている間は待機を継続する
                    }
                }
            } catch (Exception ex) {
                // 解放処理自体の失敗はユーザー操作で回復できないため,ログのみ残して継続する
                // (ここで throw すると EnableShots(true) に到達できず UI が固着する).
                Debug.WriteLine($"保存キューの解放に失敗しました: {ex}");
            }
        }

        /// <summary>
        /// 保存キュー解放待ち(DrainShots)の無通信時間の上限を求める.
        /// 「30 秒」と「撮影間隔の 2 倍」の大きい方とする(撮影間隔が 30 秒を超える連写でも,
        /// 次の 1 枚を待っている間に打ち切らないため).単発撮影・間隔 0 以下は 30 秒.
        /// </summary>
        /// <param name="intervalMs">撮影間隔(ms).単発撮影は 0</param>
        /// <returns>無通信時間の上限</returns>
        internal static TimeSpan GetDrainIdleTimeout(int intervalMs) {
            var byInterval = TimeSpan.FromMilliseconds(2.0 * intervalMs);
            return byInterval > DrainIdleTimeout ? byInterval : DrainIdleTimeout;
        }

        /// <summary>
        /// 校正フレーム処理の共通骨格(テンプレートメソッド)
        /// imgt が用意された後の校正/検出フローを担う.
        /// ゲイン反映(ApplyWhiteBalance)・収束時ログ出力(LogCalibrationConverged)・
        /// 校正情報記録(RecordCalibrationInfo)は派生クラスに委譲する.
        /// 呼び出し順序は元コードに合わせ「LogCalibrationConverged → CalcTcc → RecordCalibrationInfo」.
        /// カメラのコールバックから呼ばれるため,ここを例外境界とする(例外をコールバックへ伝えると
        /// SDK のコールバックスレッドやプレビューが止まる).校正中の例外は HandleCalibrationFailure で
        /// 通知し,チャート検出からやり直せる状態へ戻す.
        /// </summary>
        /// <param name="imgt">処理対象 Mat(前処理済み)</param>
        protected void ProcessCalibrationFrame(Mat 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");
#endif
                    }
                }

                if (!_calibrated && _calibrating == 0) {
                    DetectChart(imgt);
                }
            } catch (Exception ex) {
                HandleCalibrationFailure(ex);
            }
        }

        /// <summary>
        /// 校正中の例外の後始末(ProcessCalibrationFrame の例外境界から呼ぶ).
        /// 未校正・校正フレーム数 0・チャートマスク解放の状態へ戻し,次のフレームからチャート検出をやり直させる.
        /// 変換行列算出の後(RecordCalibrationInfo 等)で失敗して撮影ボタンを有効化済みの場合は無効に戻す.
        /// 通知は「校正に失敗しました(理由)」とし,直前に通知した内容と同じなら繰り返さない
        /// (設定不備等で毎フレーム同じ例外が起きる場合に通知とログが毎フレーム出続けないため).
        /// </summary>
        /// <param name="ex">校正中に発生した例外</param>
        private void HandleCalibrationFailure(Exception ex) {
            var wasCalibrated = _calibrated;
            _calibrated = false;
            _calibrating = 0;
            ReleaseChartMasks();
            if (wasCalibrated) {
                _form.EnableShots(false);
            }

            var message = FormatCalibrationFailure(ex);
            _lastCalibrationFailureReason = ex.Message;  // 校正完了までチャート検出の表示を差し替える
            if (!ShouldNotifyCalibrationFailure(_lastCalibrationError, message)) return;
            _lastCalibrationError = message;
            Debug.WriteLine($"校正に失敗しました: {ex}");
            _form.ShowMessage(message);
        }

        /// <summary>校正失敗の通知文(理由として例外メッセージを含める)</summary>
        /// <param name="ex">校正中に発生した例外</param>
        internal static string FormatCalibrationFailure(Exception ex) {
            return $"校正に失敗しました({ex.Message}).チャートの検出からやり直します";
        }

        /// <summary>
        /// チャート検出(ChartDetector)の表示文を決める.直前の校正失敗が無ければ検出側の表示をそのまま返し,
        /// あれば次に校正が完了するまで「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替える
        /// (検出中の表示が毎フレーム失敗の通知を上書きし,恒常的な失敗に気づけなくなるのを防ぐ).
        /// ただしチャートが上下逆の表示は設置し直しの案内のため差し替えない.
        /// </summary>
        /// <param name="lastFailureReason">直前の校正失敗の理由(失敗が無い・校正完了後は null)</param>
        /// <param name="detectionMessage">ChartDetector が出そうとした表示文</param>
        /// <returns>実際に表示する文</returns>
        internal static string FormatChartDetectionMessage(string lastFailureReason, string detectionMessage) {
            if (lastFailureReason == null) return detectionMessage;
            if (detectionMessage == ChartDetector.UpsideDownMessage) return detectionMessage;
            return $"校正に失敗しました({lastFailureReason}).チャートの検出からやり直しています";
        }

        /// <summary>
        /// 校正失敗を通知するか(直前に通知した内容と同じなら通知しない).
        /// </summary>
        /// <param name="lastNotified">直前に通知した校正失敗の通知文(未通知・校正完了後は null)</param>
        /// <param name="message">今回の通知文</param>
        internal static bool ShouldNotifyCalibrationFailure(string lastNotified, string message) {
            return !string.Equals(lastNotified, message, StringComparison.Ordinal);
        }

        /// <summary>
        /// 前回の撮影セッションの SaveThread が完了するまで待機する
        /// (派生クラスの Shot の先頭で,PrepareShotSession より前に呼ぶこと).
        /// 低スペック PC で保存と次の撮影が競合するのを防ぐ.
        /// 【重要】撮影セッションの初期化(PrepareShotSession)は必ずこの待機の後に行う.
        /// 先に _shotAborted をクリアすると,前回が中止で終わった直後の撮影で,まだ走っている
        /// 前回の SaveThread が中止フラグを false と読み,「全ての画像保存完了」と誤表示する.
        /// </summary>
        protected void WaitPreviousSaveThread() {
            if (_saveThread != null && _saveThread.IsAlive) {
                _saveThread.Join();
            }
        }

        /// <summary>
        /// 撮影セッションの開始準備(派生クラスの Shot で,SaveThread 起動より前に呼ぶこと).
        /// 中止状態と「撮影ループを完走したか」のフラグを初期化する.
        /// RunShotLoop に到達しないまま例外で抜けた場合を FinalizeShotSession が検出できるようにする.
        /// </summary>
        protected void PrepareShotSession() {
            _shotLoopFinished = false;
            _saveThreadStarted = false;
            _shotAborted = false;
            _abortReason = null;
        }

        /// <summary>
        /// 保存キュー(_shots)を今回のセッション用に作り直す
        /// (派生クラスの Shot で,SaveThread 起動より前に呼ぶこと).
        /// 新しいインスタンスを作成してから古い BlockingCollection を破棄する
        /// (順序を逆にすると,破棄と生成の間に例外が発生した場合に _shots が破棄済みインスタンスを
        ///   指したままとなり,後段の CompleteAdding が ObjectDisposedException になる).
        /// BlockingCollection の Dispose は中身の Mat を解放しないため,破棄の前に残っている Mat を
        /// 取り出して解放する(DrainShots が待機の上限で打ち切った場合等の取り残し対策).
        /// 前回の SaveThread は WaitPreviousSaveThread で終了済みのため,ここで取り出しても競合しない.
        /// </summary>
        protected void ResetShotQueue() {
            var previousShots = _shots;
            _shots = new BlockingCollection<Mat>();
            if (previousShots == null) return;
            while (previousShots.TryTake(out var remaining)) {
                remaining?.Dispose();
            }
            previousShots.Dispose();
        }

        /// <summary>
        /// 画像保存スレッド(SaveThread)を起動し,起動済みであることを記録する
        /// (派生クラスの Shot で,ResetShotQueue の後・RunShotLoop より前に呼ぶこと).
        /// 【重要】本メソッドを呼んだ以降は,どの経路でも EndShotSession に到達させること.
        /// 到達しないと SaveThread が終了通知を受け取れず永久にブロックし,
        /// 撮影ボタンを再有効化する EnableShots(true) にも到達しない.
        /// </summary>
        /// <param name="numImages">撮影枚数(保存進捗の表示に使う)</param>
        protected void StartSaveThread(int numImages) {
            // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする
            _saveThreadReady.Reset();

            var drainIdleTimeout = GetDrainIdleTimeout(_shotIntervalMs);
            var thread = new Thread(() => SaveThread(numImages, drainIdleTimeout));
            _saveThread = thread;  // 参照を保持(次回の完了待機用)
            thread.Start();
            // 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない)
            _saveThreadStarted = true;
        }

        /// <summary>
        /// SaveThread が起動完了するまで待機する(consumer 準備完了を保証する).
        /// タイムアウトしても撮影は続行する(保存が遅れるだけで,キューは失われない).
        /// </summary>
        protected void WaitSaveThreadReady() {
            if (!_saveThreadReady.Wait(SaveThreadReadyTimeout)) {
                Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました");
            }
        }

        /// <summary>
        /// 撮影セッションの終端処理(派生クラスの Shot の finally で呼ぶこと).
        /// カメラ状態の復帰(EndShot)が失敗しても SaveThread を必ず終了させるため,
        /// EndShot → FinalizeShotSession の順序をネストした finally で保証する.
        /// </summary>
        protected void EndShotSession() {
            try {
                EndShot();
            } finally {
                FinalizeShotSession();
            }
        }

        /// <summary>
        /// 保存キューへの追加終了を SaveThread へ通知する(EndShotSession からのみ呼ぶ).
        /// RunShotLoop に到達する前に例外が発生すると _shots.CompleteAdding() が呼ばれず,
        /// SaveThread が GetConsumingEnumerable で永久にブロックしてスレッドがリークし,
        /// 撮影ボタンを再有効化する EnableShots(true) にも到達しなくなる.
        /// これを防ぐため,撮影ループ未完走の場合に限り中止扱いにして CompleteAdding を呼ぶ.
        /// (完走時は RunShotLoop の finally が呼び済みのため,ここでは呼ばない)
        /// </summary>
        private void FinalizeShotSession() {
            if (_shotLoopFinished) return;      // RunShotLoop が CompleteAdding 済み(二重呼び出し防止)
            // 撮影枚数が揃っていないため中止扱いとし,SaveThread に「全て保存完了」と表示させない.
            // 中止理由は設定しない(想定外例外は呼び出し元 Form1 の例外境界が通知する).
            _shotAborted = true;
            _shots.CompleteAdding();
        }

        /// <summary>
        /// 撮影ループの共通骨格(テンプレートメソッド)
        /// 撮影ログ(Info.csv)の準備と中止状態の管理を担い,ループ本体は CaptureShots に,
        /// フレーム取得(CaptureFrame)は派生クラスに委譲する.
        /// BeginShot/EndShot は呼び出し元(派生クラスの Shot)で前後に実行すること.
        /// ループ完了後に _shots.CompleteAdding() を呼び,SaveThread に終了を通知する.
        /// フレーム取得が失敗(CaptureFailedException)した場合は,破損画像の保存を避けるため
        /// 残り枚数を撮影せずに即座にループを抜け,取得済みの画像のみを保存させる.
        /// 中止理由は _abortReason に保持するだけとし,ユーザー通知は EndShot 完了後に
        /// ShotOne/ShotMulti が NotifyShotAborted で行う(モーダル中の Shot 再入を避けるため).
        /// </summary>
        /// <param name="numImages">撮影枚数</param>
        /// <param name="interval">撮影間隔(ms)</param>
        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(IoUtil.CombineSaveFilePath(_saveFolder, filename))) {
                    WriteInfo(csv);
                    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();
                // CompleteAdding 済みであることを記録し,FinalizeShotSession の二重呼び出しを防ぐ.
                _shotLoopFinished = true;
#if MEMMONITOR
                PreviewProfiler.MarkEvent("SHOT_END");
#endif
            }
        }

        /// <summary>
        /// 撮影ループ本体.撮影ログのヘッダ行に続けて,1 枚ずつ取得して撮影時刻を CSV に記録し,_shots へ渡す.
        /// フレーム取得に失敗した場合は,破損画像の保存を避けるため残り枚数を撮影せずに打ち切る.
        /// </summary>
        /// <param name="csv">撮影ログ(Info.csv)の書き込み先</param>
        /// <param name="numImages">撮影枚数</param>
        /// <param name="interval">撮影間隔(ms)</param>
        /// <returns>全枚数を撮影できた場合は null,途中で中止した場合はその理由</returns>
        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}");
                    // 例外メッセージのカンマ・改行で列や行が崩れないよう,Note 欄は RFC 4180 でエスケープする
                    csv.WriteLine($"{i + 1},{shotTime},{IoUtil.EscapeCsvField($"撮影失敗のため中止({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;
        }

        /// <summary>
        /// チャートの検出
        /// </summary>
        /// <param name="img"></param>
        protected void DetectChart(Mat img) {
            // チャート検出・マスク生成は ChartDetector に委譲する
            var masks = _chartDetector.DetectChart(img);
            if (masks == null) return;

            // 旧マスク(校正の途中で失敗して再検出する場合等)を解放してから差し替える
            ReleaseChartMasks();
            _chartMasks.AddRange(masks);
            _calibrating = Config.GetInt("Calib/Frames");
#if MEMMONITOR
            PreviewProfiler.MarkEvent("CALIB_START");
#endif
        }

        /// <summary>
        /// チャートマスク(_chartMasks の各 Mat)を解放して空にする.
        /// _chartMasks はカメラのコールバックスレッド(ProcessCalibrationFrame)だけが読むため,
        /// 切断時(Disconnect)はストリーム停止の後に呼ぶこと(コールバックが動いている間に解放しない).
        /// </summary>
        protected void ReleaseChartMasks() {
            foreach (var mask in _chartMasks) {
                mask?.Dispose();
            }
            _chartMasks.Clear();
        }

        /// <summary>
        /// ファイル名にチャネル数を追加する
        /// </summary>
        /// <param name="config"></param>
        /// <param name="channel"></param>
        /// <returns></returns>
        private string GetFilenameWithChannel(string config, int channel) {
            var filename = Config.GetString(config);
            filename = filename.Replace("{CN}", $"{channel:00}");
            return filename;
        }

        /// <summary>
        /// チャートに基づく変換行列の計算
        /// </summary>
        /// <param name="img"></param>
        protected void CalcTcc(Mat img) {

            bool imagesSaved;
            // 変換行列の計算(tccRgb・imgRois はここで所有し,使用後に確実に解放する)
            using (var tccRgb = GetTccRgb(img))
            using (var imgRois = GetTccRoisImage(img)) {

                // データ保存
                SetSaveFolder("校正");
                SetInfo("校正データ", _saveFolder);
                // 校正画像は記録用であり,保存に失敗しても変換行列(メモリ上)は有効なため校正は続ける.
                // 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(FormatCalibrationDone(imagesSaved));
            _form.EnableShots();
            _calibrated = true;
            _lastCalibrationError = null;   // 次に失敗したときは改めて通知する
            _lastCalibrationFailureReason = null;   // チャート検出の表示を従来どおりに戻す
        }

        /// <summary>自動校正完了の表示文(校正画像の保存に失敗した場合は警告を添える)</summary>
        /// <param name="imagesSaved">校正画像(TccImage・TccRoisImage)を両方とも保存できたか</param>
        internal static string FormatCalibrationDone(bool imagesSaved) {
            return imagesSaved ? "自動校正完了" : "自動校正完了(校正画像の保存に失敗)";
        }

        /// <summary>
        /// 画像からチャートのRGB値算出
        /// </summary>
        /// <param name="img"></param>
        /// <returns></returns>
        private Mat GetTccRgb(Mat img) {
            var arrRGB = new Mat(24, 3, MatType.CV_64FC1);
            for (int i = 0; i < _chartMasks.Count; i++) {
                var rgb = Cv2.Mean(img, _chartMasks[i]);
                arrRGB.At<double>(i, 0) = rgb.Val0; // Blue
                arrRGB.At<double>(i, 1) = rgb.Val1; // Green
                arrRGB.At<double>(i, 2) = rgb.Val2; // Red
            }
            return arrRGB;
        }

        /// <summary>
        /// TCCのROI画像を取得
        /// </summary>
        /// <param name="img"></param>
        /// <returns></returns>
        private Mat GetTccRoisImage(Mat img) {
            var imgRois = img.Clone();
            for (int i = 0; i < _chartMasks.Count; i++) {
                imgRois.SetTo(new Scalar(0, 200, 0), _chartMasks[i]);
            }
            return imgRois;
        }

        /// <summary>
        /// ゲイン値の更新比率計算
        /// </summary>
        /// <param name="value">現在の計測値</param>
        /// <param name="target">目標値</param>
        /// <returns>更新比率</returns>
        protected float GetRatio(float value, float target) {
            return GainCalc.CalcRatio(value, target, UpdateRate);
        }

        /// <summary>
        /// 保存フォルダの取得
        /// </summary>
        /// <param name="note"></param>
        protected void SetSaveFolder(string note) {
            _saveFolder = IoUtil.CreateSaveFolder(note);
        }

        /// <summary>
        /// 設定値を保存する
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        protected void SetInfo(string key, string value) {
            if (_ShotInfo.ContainsKey(key)) {
                _ShotInfo[key] = value;
            } else {
                _ShotInfo.Add(key, value);
            }
        }

        /// <summary>
        /// 設定値をCSVファイルに書き込む
        /// </summary>
        /// <param name="sw"></param>
        protected void WriteInfo(StreamWriter sw) {
            IoUtil.WriteInfo(sw, _ShotInfo);
        }
    }
}