ImageAcqView.xaml.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Linq;
  5. using System.Threading.Tasks;
  6. using System.Windows;
  7. using System.Windows.Controls;
  8. using System.Windows.Input;
  9. using TeamAAS.Camera;
  10. using TeamAAS.Core;
  11. using TeamAAS.Camera.Enums;
  12. using TeamAAS.Camera.Images;
  13. using TeamAAS.Camera.Interfaces;
  14. using TeamAAS.FlowEditor.Plugins;
  15. using Plugins.Standard.Models;
  16. namespace Plugins.Standard.Views
  17. {
  18. /// <summary>
  19. /// 图像采集插件的自定义编辑视图(UserControl)- 承载在 PluginViewDialog 中。
  20. /// DataContext 由宿主设为 ImageAcqModel(宿主执行回调会把 DataContext 当作模型回写,故不可替换)。
  21. /// 支持三种图像来源:相机采集 / 本地图像 / 本地目录,并复用 ImageDisplayControl 做缩放平移预览。
  22. /// - IPluginViewSave:确定时把界面值回写模型;
  23. /// - IPluginRunFeedback:窗体「执行」前后同步预览(执行完成后显示采集到的图像);
  24. /// - IPluginViewTip:把提示写到承载窗体底部提示栏,并给带 Tag 的按钮挂悬停提示。
  25. /// </summary>
  26. public partial class ImageAcqView : UserControl, IPluginViewSave, IPluginRunFeedback, IPluginViewTip
  27. {
  28. private static readonly string[] ImageExtensions =
  29. { ".bmp", ".jpg", ".jpeg", ".png", ".tif", ".tiff" };
  30. private volatile bool _busy;
  31. /// <summary>正在程序化刷新界面(抑制 SelectionChanged 回写模型)</summary>
  32. private bool _syncingUi;
  33. /// <summary>
  34. /// 缓存的模型引用。DataContext 是 WPF 依赖属性(DispatcherObject),只能在 UI 线程读取;
  35. /// 而 IPluginRunFeedback.EndRun 由宿主在后台线程回调,若在其中直接读 DataContext 会触发
  36. /// VerifyAccess 抛出“调用线程无法访问此对象”。故在 UI 线程(DataContextChanged)缓存模型,
  37. /// 后台线程只读该普通字段,规避跨线程访问依赖对象。
  38. /// </summary>
  39. private ImageAcqModel _model;
  40. /// <summary>当前编辑的模型(宿主注入的 DataContext,已在 UI 线程缓存)</summary>
  41. private ImageAcqModel Model => _model;
  42. public ImageAcqView()
  43. {
  44. InitializeComponent();
  45. DataContextChanged += (s, e) => _model = e.NewValue as ImageAcqModel;
  46. Loaded += OnLoaded;
  47. }
  48. private void OnLoaded(object sender, RoutedEventArgs e)
  49. {
  50. LoadCameraList();
  51. SyncUiFromModel();
  52. // 若模型已有上次采集/加载的图像(例如再次打开节点),直接回显
  53. if (Model?.PreviewImage != null)
  54. ShowGenImage(Model.PreviewImage, DescribeImage(Model.PreviewImage));
  55. HookHoverHints(this);
  56. }
  57. #region 相机列表 & UI 同步
  58. private void LoadCameraList()
  59. {
  60. CmbCamera.Items.Clear();
  61. try
  62. {
  63. foreach (var d in CoreManager.Camera.Devices)
  64. {
  65. if (!string.IsNullOrEmpty(d.Name) && !CmbCamera.Items.Contains(d.Name))
  66. CmbCamera.Items.Add(d.Name);
  67. }
  68. }
  69. catch { }
  70. }
  71. /// <summary>把模型当前值同步到界面(加载时、以及参数设置窗体确认后调用)</summary>
  72. private void SyncUiFromModel()
  73. {
  74. if (Model == null) return;
  75. _syncingUi = true;
  76. try
  77. {
  78. if (!string.IsNullOrEmpty(Model.CameraName) && CmbCamera.Items.Contains(Model.CameraName))
  79. CmbCamera.SelectedItem = Model.CameraName;
  80. else if (CmbCamera.Items.Count > 0 && CmbCamera.SelectedItem == null)
  81. CmbCamera.SelectedIndex = 0;
  82. TxtImagePath.Text = Model.LocalImagePath ?? "";
  83. TxtDirPath.Text = Model.LocalImageDir ?? "";
  84. }
  85. finally
  86. {
  87. _syncingUi = false;
  88. }
  89. // 模型里的相机已不存在时,回落到列表首项并同步回模型
  90. if (CmbCamera.SelectedItem is string sel && Model.CameraName != sel)
  91. Model.CameraName = sel;
  92. UpdateSourcePanels();
  93. RefreshTriggerModes();
  94. if (Model.Source == AcqImageSource.本地目录 &&
  95. !string.IsNullOrEmpty(Model.LocalImageDir) && LstDirFiles.Items.Count == 0)
  96. PopulateDirList(Model.LocalImageDir, autoPreview: false);
  97. }
  98. /// <summary>根据图像来源切换各配置面板的可见性与单选状态</summary>
  99. private void UpdateSourcePanels()
  100. {
  101. var src = Model?.Source ?? AcqImageSource.相机采集;
  102. PanelCamera.Visibility = src == AcqImageSource.相机采集 ? Visibility.Visible : Visibility.Collapsed;
  103. PanelFile.Visibility = src == AcqImageSource.本地图像 ? Visibility.Visible : Visibility.Collapsed;
  104. PanelDir.Visibility = src == AcqImageSource.本地目录 ? Visibility.Visible : Visibility.Collapsed;
  105. PanelLocalOpts.Visibility =
  106. (src == AcqImageSource.本地图像 || src == AcqImageSource.本地目录)
  107. ? Visibility.Visible : Visibility.Collapsed;
  108. RbCamera.IsChecked = src == AcqImageSource.相机采集;
  109. RbFile.IsChecked = src == AcqImageSource.本地图像;
  110. RbDir.IsChecked = src == AcqImageSource.本地目录;
  111. TxtSourceTag.Text = src == AcqImageSource.相机采集 ? "· 相机"
  112. : src == AcqImageSource.本地图像 ? "· 本地文件" : "· 本地目录";
  113. }
  114. #endregion
  115. #region 事件:来源切换 / 相机选择
  116. private void Source_Changed(object sender, RoutedEventArgs e)
  117. {
  118. if (Model == null) return;
  119. if (ReferenceEquals(sender, RbCamera)) Model.Source = AcqImageSource.相机采集;
  120. else if (ReferenceEquals(sender, RbFile)) Model.Source = AcqImageSource.本地图像;
  121. else if (ReferenceEquals(sender, RbDir)) Model.Source = AcqImageSource.本地目录;
  122. UpdateSourcePanels();
  123. if (Model.Source == AcqImageSource.本地目录 &&
  124. !string.IsNullOrEmpty(Model.LocalImageDir) && LstDirFiles.Items.Count == 0)
  125. PopulateDirList(Model.LocalImageDir, autoPreview: false);
  126. }
  127. private void CmbCamera_SelectionChanged(object sender, SelectionChangedEventArgs e)
  128. {
  129. if (_syncingUi) return;
  130. if (Model != null && CmbCamera.SelectedItem is string name)
  131. {
  132. Model.CameraName = name;
  133. // 换相机后触发能力可能不同(如 UVC 只有连续采集),刷新可选项
  134. RefreshTriggerModes();
  135. }
  136. }
  137. #endregion
  138. #region 触发模式(选项随相机能力动态变化)
  139. /// <summary>按名称取当前选中的相机实例(未找到返回 null)</summary>
  140. private ICamera CurrentCamera
  141. {
  142. get
  143. {
  144. var name = _model?.CameraName;
  145. if (string.IsNullOrEmpty(name)) return null;
  146. try { return CoreManager.Camera.Devices.FirstOrDefault(c => c.Name == name); }
  147. catch { return null; }
  148. }
  149. }
  150. /// <summary>
  151. /// 按当前选中相机的能力刷新触发模式下拉——各品牌 SDK 支持的模式不同,
  152. /// 所以选项列表是动态的(UVC/Cognex 只有“跟随相机/连续采集”,工业相机有全部 5 种)。
  153. /// </summary>
  154. private void RefreshTriggerModes()
  155. {
  156. _syncingUi = true;
  157. try
  158. {
  159. CmbTrigger.Items.Clear();
  160. var cam = CurrentCamera;
  161. IReadOnlyList<CameraTriggerMode> modes = cam?.SupportedTriggerModes;
  162. if (modes == null || modes.Count == 0)
  163. modes = new[] { CameraTriggerMode.跟随相机 };
  164. foreach (var m in modes)
  165. CmbTrigger.Items.Add(m);
  166. // 模型当前值若不被该相机支持(例如从 Basler 换到 UVC),回落到“跟随相机”
  167. var want = _model?.TriggerMode ?? CameraTriggerMode.跟随相机;
  168. CmbTrigger.SelectedItem = CmbTrigger.Items.Contains(want) ? want : CameraTriggerMode.跟随相机;
  169. TxtTriggerHint.Text = cam == null
  170. ? "未选择相机,仅可设「跟随相机」"
  171. : $"该相机支持 {modes.Count} 种:{string.Join("、", modes)}";
  172. }
  173. finally
  174. {
  175. _syncingUi = false;
  176. }
  177. }
  178. private void CmbTrigger_SelectionChanged(object sender, SelectionChangedEventArgs e)
  179. {
  180. if (_syncingUi) return;
  181. if (_model != null && CmbTrigger.SelectedItem is CameraTriggerMode mode)
  182. _model.TriggerMode = mode;
  183. }
  184. #endregion
  185. #region 事件:采集预览 / 浏览文件 / 浏览目录
  186. private async void BtnGrabPreview_Click(object sender, RoutedEventArgs e)
  187. {
  188. if (_busy) return;
  189. var model = Model;
  190. if (model == null) return;
  191. if (string.IsNullOrEmpty(model.CameraName))
  192. {
  193. TxtInfo.Text = "请先选择相机设备";
  194. return;
  195. }
  196. _busy = true;
  197. BtnGrab.IsEnabled = false;
  198. TxtInfo.Text = "采集中...";
  199. try
  200. {
  201. var img = await Task.Run(() => GrabFromCamera(model));
  202. if (img != null)
  203. {
  204. model.PreviewImage = img;
  205. ShowGenImage(img, $"相机: {model.CameraName} {DescribeImage(img)}");
  206. }
  207. else
  208. {
  209. TxtInfo.Text = "采集失败,请检查相机连接或参数";
  210. }
  211. }
  212. catch (Exception ex)
  213. {
  214. TxtInfo.Text = $"采集异常: {ex.Message}";
  215. }
  216. finally
  217. {
  218. _busy = false;
  219. BtnGrab.IsEnabled = true;
  220. }
  221. }
  222. /// <summary>直接从相机采集一帧(后台线程),仅用于视图内即时预览</summary>
  223. private static GenImage GrabFromCamera(ImageAcqModel model)
  224. {
  225. var camera = CoreManager.Camera.Devices.FirstOrDefault(c => c.Name == model.CameraName);
  226. if (camera == null) return null;
  227. if (model.AutoOpen && !camera.IsConnected)
  228. {
  229. if (!camera.OpenDevice()) return null;
  230. }
  231. // 与正式执行保持一致:显式指定且相机支持时才下发触发模式
  232. if (model.TriggerMode != CameraTriggerMode.跟随相机 &&
  233. camera.SupportedTriggerModes != null &&
  234. camera.SupportedTriggerModes.Contains(model.TriggerMode))
  235. camera.SetTriggerMode(model.TriggerMode);
  236. float exposure = model.ExposureTime?.Value ?? 0f;
  237. if (exposure > 0) camera.SetExposureTime(exposure);
  238. float gain = model.Gain?.Value ?? 0f;
  239. if (gain >= 0) camera.SetGain(gain);
  240. float gamma = model.Gamma?.Value ?? 1f;
  241. if (gamma > 0) camera.SetGamma(gamma);
  242. return camera.Grab();
  243. }
  244. private void BtnBrowseFile_Click(object sender, RoutedEventArgs e)
  245. {
  246. var dlg = new Microsoft.Win32.OpenFileDialog
  247. {
  248. Filter = "图像文件|*.bmp;*.jpg;*.jpeg;*.png;*.tif;*.tiff|所有文件|*.*"
  249. };
  250. if (!string.IsNullOrEmpty(Model?.LocalImagePath) && File.Exists(Model.LocalImagePath))
  251. {
  252. dlg.InitialDirectory = Path.GetDirectoryName(Model.LocalImagePath);
  253. dlg.FileName = Path.GetFileName(Model.LocalImagePath);
  254. }
  255. if (dlg.ShowDialog() == true)
  256. {
  257. if (Model != null) Model.LocalImagePath = dlg.FileName;
  258. TxtImagePath.Text = dlg.FileName;
  259. PreviewFile(dlg.FileName);
  260. }
  261. }
  262. private void BtnBrowseDir_Click(object sender, RoutedEventArgs e)
  263. {
  264. using (var dlg = new System.Windows.Forms.FolderBrowserDialog())
  265. {
  266. if (!string.IsNullOrEmpty(Model?.LocalImageDir) && Directory.Exists(Model.LocalImageDir))
  267. dlg.SelectedPath = Model.LocalImageDir;
  268. if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
  269. {
  270. if (Model != null) Model.LocalImageDir = dlg.SelectedPath;
  271. TxtDirPath.Text = dlg.SelectedPath;
  272. PopulateDirList(dlg.SelectedPath, autoPreview: true);
  273. }
  274. }
  275. }
  276. private void LstDirFiles_MouseDoubleClick(object sender, MouseButtonEventArgs e)
  277. {
  278. if (LstDirFiles.SelectedItem is string path)
  279. PreviewFile(path);
  280. }
  281. /// <summary>枚举目录内的图像文件填充列表,可选自动预览第一张</summary>
  282. private void PopulateDirList(string dir, bool autoPreview)
  283. {
  284. LstDirFiles.Items.Clear();
  285. if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
  286. {
  287. TxtSourceTag.Text = "· 本地目录(空)";
  288. return;
  289. }
  290. var files = Directory.EnumerateFiles(dir, "*.*", SearchOption.TopDirectoryOnly)
  291. .Where(f => ImageExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
  292. .OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
  293. .ToList();
  294. foreach (var f in files)
  295. LstDirFiles.Items.Add(f);
  296. TxtSourceTag.Text = $"· 本地目录({files.Count} 张)";
  297. if (autoPreview && files.Count > 0)
  298. PreviewFile(files[0]);
  299. }
  300. /// <summary>加载并预览单个本地图像文件(按选项应用 Gamma)</summary>
  301. private void PreviewFile(string path)
  302. {
  303. if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
  304. try
  305. {
  306. var img = ImageAcqPlugin.LoadImageFile(path);
  307. if (img == null)
  308. {
  309. TxtInfo.Text = $"无法加载图像: {Path.GetFileName(path)}";
  310. return;
  311. }
  312. if (Model != null)
  313. {
  314. if (Model.ApplyGamma)
  315. {
  316. float g = Model.Gamma?.Value ?? 1f;
  317. if (g > 0) img.ApplyGamma(g);
  318. }
  319. Model.PreviewImage = img;
  320. }
  321. ShowGenImage(img, $"{Path.GetFileName(path)} {DescribeImage(img)}");
  322. }
  323. catch (Exception ex)
  324. {
  325. TxtInfo.Text = $"预览失败: {ex.Message}";
  326. }
  327. }
  328. #endregion
  329. #region 事件:参数设置
  330. private void BtnProperties_Click(object sender, RoutedEventArgs e)
  331. {
  332. if (Model == null) return;
  333. // 先把界面上的即时编辑写回模型,避免属性窗体覆盖未保存的输入
  334. SaveChanges();
  335. if (TeamAAS.DialogHelper.EditProperties(Model, $"图像采集参数 - {Model.NodeName}"))
  336. SyncUiFromModel();
  337. }
  338. #endregion
  339. #region 图像显示
  340. private static string DescribeImage(GenImage img)
  341. => img == null ? "" : $"分辨率: {img.Width} x {img.Height} 格式: {img.Format}";
  342. /// <summary>把 GenImage 显示到预览控件(线程安全,内部切回 UI 线程)</summary>
  343. private void ShowGenImage(GenImage img, string info)
  344. {
  345. if (img == null)
  346. {
  347. Dispatcher.Invoke(() =>
  348. {
  349. Display.Clear();
  350. TxtInfo.Text = info ?? "无图像";
  351. });
  352. return;
  353. }
  354. Dispatcher.Invoke(() =>
  355. {
  356. try
  357. {
  358. // SetImage(Bitmap) 内部完成 Bitmap→BitmapSource 转换并释放该 Bitmap
  359. Display.SetImage(img.ToBitmap());
  360. TxtInfo.Text = info ?? DescribeImage(img);
  361. }
  362. catch (Exception ex)
  363. {
  364. TxtInfo.Text = $"显示失败: {ex.Message}";
  365. }
  366. });
  367. }
  368. #endregion
  369. #region IPluginViewSave
  370. /// <summary>窗体「确定」时把界面值回写模型(来源、相机、路径)</summary>
  371. public void SaveChanges()
  372. {
  373. if (Model == null) return;
  374. if (CmbCamera.SelectedItem is string cam)
  375. Model.CameraName = cam;
  376. if (CmbTrigger.SelectedItem is CameraTriggerMode tm)
  377. Model.TriggerMode = tm;
  378. Model.LocalImagePath = (TxtImagePath.Text ?? "").Trim();
  379. Model.LocalImageDir = (TxtDirPath.Text ?? "").Trim();
  380. if (RbFile.IsChecked == true) Model.Source = AcqImageSource.本地图像;
  381. else if (RbDir.IsChecked == true) Model.Source = AcqImageSource.本地目录;
  382. else Model.Source = AcqImageSource.相机采集;
  383. }
  384. #endregion
  385. #region IPluginRunFeedback
  386. public void BeginRun()
  387. {
  388. Dispatcher.Invoke(() =>
  389. {
  390. BtnGrab.IsEnabled = false;
  391. TxtInfo.Text = "执行中...";
  392. });
  393. }
  394. public void EndRun()
  395. {
  396. // 全程在 UI 线程执行:读取模型预览图、刷新按钮与画面,避免跨线程访问 WPF 依赖对象
  397. Dispatcher.Invoke(() =>
  398. {
  399. BtnGrab.IsEnabled = true;
  400. var img = _model?.PreviewImage;
  401. if (img != null)
  402. ShowGenImage(img, DescribeImage(img));
  403. else
  404. TxtInfo.Text = "执行结束,无图像输出";
  405. });
  406. }
  407. #endregion
  408. #region IPluginViewTip 底部提示栏
  409. private readonly HashSet<DependencyObject> _hoverHooked = new HashSet<DependencyObject>();
  410. /// <summary>承载本视图的基窗体(PluginViewDialog),未承载时为 null</summary>
  411. private TeamAAS.Dialogs.Dialogs.PluginViewDialog HostDialog =>
  412. Window.GetWindow(this) as TeamAAS.Dialogs.Dialogs.PluginViewDialog;
  413. public void ShowTip(string message) => HostDialog?.ShowTip(message);
  414. public void ShowHoverHint(string message) => HostDialog?.ShowHoverHint(message);
  415. private void HoverEl_MouseEnter(object sender, MouseEventArgs e)
  416. {
  417. if ((sender as FrameworkElement)?.Tag is string tip && !string.IsNullOrWhiteSpace(tip))
  418. ShowHoverHint(tip);
  419. }
  420. private void HoverEl_MouseLeave(object sender, MouseEventArgs e) => ShowHoverHint(null);
  421. /// <summary>给所有 Tag 为非空字符串的按钮挂悬停提示(显示到承载窗体底部提示栏)</summary>
  422. private void HookHoverHints(DependencyObject root)
  423. {
  424. if (root == null) return;
  425. int count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
  426. for (int i = 0; i < count; i++)
  427. {
  428. var child = System.Windows.Media.VisualTreeHelper.GetChild(root, i);
  429. if (child is System.Windows.Controls.Primitives.ButtonBase btn
  430. && !_hoverHooked.Contains(btn)
  431. && btn.Tag is string tip && !string.IsNullOrWhiteSpace(tip))
  432. {
  433. btn.MouseEnter += HoverEl_MouseEnter;
  434. btn.MouseLeave += HoverEl_MouseLeave;
  435. _hoverHooked.Add(btn);
  436. }
  437. HookHoverHints(child);
  438. }
  439. }
  440. #endregion
  441. }
  442. }