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();
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起動完了の同期
// 撮影が途中で中止されたか.撮影スレッド(RunShotLoop)が書き,別スレッドの SaveThread が
// 読むため volatile とする(_abortReason とは可視性の前提が異なるため統合しない).
protected volatile bool _shotAborted = false;
// 中止理由(EndShot 完了後にユーザー通知するため保持する.通知時にクリアする).
// 【前提】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;
/// <summary>
/// 今回の撮影セッションで画像保存スレッド(SaveThread)を起動したか.
/// 起動済みであれば,撮影ボタンの再有効化(EnableShots(true))は SaveThread の finally が
/// 必ず行う(CompleteAdding には全経路で到達するため SaveThread は必ず完走する).
/// このとき呼び出し元(Form1 の例外境界)が先に再有効化すると,保存中に次の撮影へ再入でき,
/// UI スレッドの _saveThread.Join() と SaveThread 側の Control.Invoke が相互待機して
/// アプリがハングする.そのため呼び出し元は本プロパティが false の場合のみ再有効化すること.
/// </summary>
public bool IsSaveThreadStarted => _saveThreadStarted;
// プライベートメンバ
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 = new ChartDetector(_form.ShowMessage);
}
/// <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() {
SetSaveFolder(_form.GetDataName());
SetInfo("データ名", _form.GetDataName());
SetInfo("撮影枚数", "1");
Shot();
FinishShot("Sound/OneShotDone");
}
/// <summary>
/// 複数画像撮影
/// </summary>
public void ShotMulti() {
SetSaveFolder(_form.GetDataName());
SetInfo("データ名", _form.GetDataName());
SetInfo("撮影枚数", $"{_form.GetNumMultiShots()}");
SetInfo("撮影間隔(ms)", $"{_form.GetMultiShotsInterval()}");
Shot(_form.GetNumMultiShots(), _form.GetMultiShotsInterval());
FinishShot("Sound/MultiShotDone");
}
/// <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}");
IoUtil.SaveImage(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}");
IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, filename), converted);
}
}
}
/// <summary>
/// 画像保存スレッド処理.
/// バックグラウンドスレッドの未処理例外は .NET Framework 4.8 ではプロセスを即終了させるため,
/// ここに例外境界を置き,保存失敗(ディスク満杯・パス不正・OpenCV 例外等)でもアプリを継続させる.
/// ユーザー通知は Form1(ShowError)へ委譲し,本クラスからは MessageBox を直接呼ばない.
/// finally では未消費の Mat を解放し,撮影ボタンを必ず再有効化する(UI 固着の防止).
/// </summary>
/// <param name="numImages"></param>
protected void SaveThread(int numImages) {
// 消費対象は起動時点の _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(shots);
if (saveError != null) {
_form.ShowError($"画像の保存に失敗しました.\r\n{saveError}");
}
// 撮影ボタンの再有効化は本スレッドの責務(Form1 の例外境界は再有効化しない).
_form.EnableShots(true);
}
}
/// <summary>
/// 保存キューに残った Mat を取り出して解放する(例外で保存ループを抜けた場合のリーク防止).
/// producer が撮影を続けている可能性があるため,CompleteAdding 済みかつ空になるまで待つ.
/// CompleteAdding には全経路で到達するが(RunShotLoop / FinalizeShotSession),
/// 万一到達しない異常時にスレッドが残らないよう待機時間の上限を設ける.
/// </summary>
/// <param name="shots">対象の保存キュー</param>
private void DrainShots(BlockingCollection<Mat> 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);
/// <summary>
/// 校正フレーム処理の共通骨格(テンプレートメソッド)
/// imgt が用意された後の校正/検出フローを担う.
/// ゲイン反映(ApplyWhiteBalance)・収束時ログ出力(LogCalibrationConverged)・
/// 校正情報記録(RecordCalibrationInfo)は派生クラスに委譲する.
/// 呼び出し順序は元コードに合わせ「LogCalibrationConverged → CalcTcc → RecordCalibrationInfo」.
/// </summary>
/// <param name="imgt">処理対象 Mat(前処理済み)</param>
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);
#if MEMMONITOR
PreviewProfiler.MarkEvent("CALIB_END");
#endif
}
}
if (!_calibrated && _calibrating == 0) {
DetectChart(imgt);
}
}
/// <summary>
/// 撮影セッションの開始準備(派生クラスの Shot で,SaveThread 起動より前に呼ぶこと).
/// 中止状態と「撮影ループを完走したか」のフラグを初期化する.
/// RunShotLoop に到達しないまま例外で抜けた場合を FinalizeShotSession が検出できるようにする.
/// </summary>
protected void PrepareShotSession() {
_shotLoopFinished = false;
_saveThreadStarted = false;
_shotAborted = false;
_abortReason = null;
}
/// <summary>
/// SaveThread の起動を記録する(派生クラスの Shot で Thread.Start() の直後に呼ぶこと).
/// 以降は EnableShots(true) の責任が SaveThread に移る(IsSaveThreadStarted 参照).
/// </summary>
protected void MarkSaveThreadStarted() {
_saveThreadStarted = true;
}
/// <summary>
/// 撮影セッションの終端処理(派生クラスの Shot の finally で,撮影終了処理の後に呼ぶこと).
/// RunShotLoop に到達する前に例外が発生すると _shots.CompleteAdding() が呼ばれず,
/// SaveThread が GetConsumingEnumerable で永久にブロックしてスレッドがリークし,
/// 撮影ボタンを再有効化する EnableShots(true) にも到達しなくなる.
/// これを防ぐため,撮影ループ未完走の場合に限り中止扱いにして CompleteAdding を呼ぶ.
/// (完走時は RunShotLoop の finally が呼び済みのため,ここでは呼ばない)
/// </summary>
protected 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}");
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;
}
/// <summary>
/// チャートの検出
/// </summary>
/// <param name="img"></param>
protected void DetectChart(Mat img) {
// チャート検出・マスク生成は ChartDetector に委譲する
var masks = _chartDetector.DetectChart(img);
if (masks == null) return;
_chartMasks.Clear();
_chartMasks.AddRange(masks);
_calibrating = Config.GetInt("Calib/Frames");
#if MEMMONITOR
PreviewProfiler.MarkEvent("CALIB_START");
#endif
}
/// <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) {
// 変換行列の計算(tccRgb・imgRois はここで所有し,使用後に確実に解放する)
using (var tccRgb = GetTccRgb(img))
using (var imgRois = GetTccRoisImage(img)) {
// データ保存
SetSaveFolder("校正");
SetInfo("校正データ", _saveFolder);
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);
}
IoUtil.PlaySound(Config.GetString("Sound/CalibDone"));
_form.ShowMessage("自動校正完了");
_form.EnableShots();
_calibrated = true;
}
/// <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);
}
}
}