视觉修改

This commit is contained in:
17860779768
2025-08-25 16:33:58 +08:00
commit 2e46747ba9
49 changed files with 11062 additions and 0 deletions

63
.gitattributes vendored Normal file
View File

@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

363
.gitignore vendored Normal file
View File

@@ -0,0 +1,363 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

View File

@@ -0,0 +1,838 @@
using Check.Main.Common;
using Check.Main.Infer;
using Check.Main.Result;
using Check.Main.UI;
using OpenCvSharp;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using System.Timers;
using System.Windows.Forms;
namespace Check.Main.Camera
{
/// <summary>
/// 静态全局相机管理器,负责所有相机的生命周期、配置应用和多相机同步
/// </summary>
public static class CameraManager
{
// 活动的相机实例字典,键为相机名称
public static Dictionary<string, HikvisionCamera> ActiveCameras { get; } = new Dictionary<string, HikvisionCamera>();
// 相机对应的图像显示窗口字典
//public static Dictionary<string, FormImageDisplay> CameraDisplays { get; } = new Dictionary<string, FormImageDisplay>();
public static Dictionary<string, FormImageDisplay> OriginalImageDisplays { get; } = new Dictionary<string, FormImageDisplay>();
public static Dictionary<string, FormImageDisplay> ResultImageDisplays { get; } = new Dictionary<string, FormImageDisplay>();
// --- 多相机同步逻辑 ---
// 产品检测队列
private static readonly Queue<ProductResult> ProductQueue = new Queue<ProductResult>();
// 队列锁,保证线程安全
private static readonly object QueueLock = new object();
// 当前启用的相机数量,用于判断产品是否检测完毕
private static int EnabledCameraCount = 0;
//public static event EventHandler<DetectionResultEventArgs> OnDetectionCompleted;
// public static bool IsDetectionRunning { get; private set; } = false;
// 产品ID计数器
private static long _productCounter = 0;
// 用于同步计数器访问的锁虽然long的自增是原子操作但为清晰和未来扩展使用锁是好习惯
private static readonly object _counterLock = new object();
// --- 新增:硬触发模拟器 ---
private static readonly System.Timers.Timer _hardwareTriggerSimulator;
/// <summary>
/// 获取或设置模拟硬触发的间隔时间(毫秒)。
/// </summary>
public static double TriggerInterval { get; set; } = 1000; // 默认1秒触发一次
/// <summary>
/// 获取一个值,该值指示硬件触发模拟器当前是否正在运行。
/// </summary>
public static bool IsHardwareTriggerSimulating { get; private set; } = false;
/// <summary>
/// 静态构造函数,用于一次性初始化静态资源。
/// </summary>
static CameraManager()
{
//初始化硬触发模拟器
_hardwareTriggerSimulator = new System.Timers.Timer();
_hardwareTriggerSimulator.Elapsed += OnHardwareTriggerTimerElapsed;
_hardwareTriggerSimulator.AutoReset = true; // 确保定时器持续触发
_hardwareTriggerSimulator.Enabled = false; // 默认不启动
}
//// 事件用于向UI发送日志消息
//public static event Action<string> OnLogMessage;
//// 用于写入日志文件的 StreamWriter
//private static StreamWriter _logFileWriter;
////私有的、静态的、只读的对象,专门用作线程同步的“锁”。
//private static readonly object _logLock = new object();
/// <summary>
/// 初始化文件日志记录器。应在程序启动时调用一次。
/// </summary>
//public static void InitializeLogger()
//{
// try
// {
// string logDirectory = Path.Combine(Application.StartupPath, "Logs");
// Directory.CreateDirectory(logDirectory); // 确保Logs文件夹存在
// string logFileName = $"Log_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt";
// string logFilePath = Path.Combine(logDirectory, logFileName);
// // 创建StreamWriter设置为追加模式和自动刷新
// _logFileWriter = new StreamWriter(logFilePath, append: true, encoding: System.Text.Encoding.UTF8)
// {
// AutoFlush = true
// };
// Log("文件日志记录器已初始化。");
// }
// catch (Exception ex)
// {
// // 如果文件日志初始化失败在UI上报告错误
// OnLogMessage?.Invoke($"[CRITICAL] 文件日志初始化失败: {ex.Message}");
// }
//}
/// <summary>
/// 【新增】一个完整的业务流程方法:
/// 1. 根据配置初始化所有相机并显示它们的窗口。
/// 2. 在所有窗口都显示后,命令所有相机开始采集。
/// 这个方法是响应“开启设备”或“应用配置并启动”按钮的理想入口点。
/// </summary>
/// <param name="settingsList">要应用的相机配置列表。</param>
/// <param name="mainForm">主窗体,用于停靠显示窗口。</param>
//public static void ApplyConfigurationAndStartGrabbing(List<CameraSettings> settingsList, FrmMain mainForm)
//{
// ThreadSafeLogger.Log("开始执行“开启设备”流程...");
// // 步骤 1: 初始化所有相机和UI窗口
// // Initialize 方法会负责 Shutdown 旧实例、创建新实例、打开硬件、显示窗口等。
// // 因为 Initialize 方法中的 displayForm.Show() 是非阻塞的,它会立即返回,
// // 窗体的创建和显示过程会被调度到UI线程的消息队列中。
// Initialize(settingsList, mainForm);
// // 检查是否有任何相机成功初始化
// if (ActiveCameras.Count == 0)
// {
// ThreadSafeLogger.Log("“开启设备”流程中止,因为没有相机被成功初始化。");
// return;
// }
// // 步骤 2: 在 Initialize 完成后(意味着所有窗口都已创建并 Show开始采集
// // 这个调用会紧接着 Initialize 执行,此时窗体可能还在绘制过程中,但这没关系。
// // StartAll() 会启动后台线程开始拉取图像,一旦图像到达,就会通过事件推送到已经存在的窗体上。
// ThreadSafeLogger.Log("所有相机窗口已创建,现在开始采集图像...");
// StartAll();
// ThreadSafeLogger.Log("“开启设备”流程已完成。相机正在采集中。");
//}
///// <summary>
///// 根据配置列表初始化或更新所有相机
///// </summary>
//public static void Initialize(List<CameraSettings> settingsList, FrmMain mainForm)
//{
// // 先停止并释放所有旧的相机
// Shutdown();
// ThreadSafeLogger.Log("开始应用新的相机配置...");
// EnabledCameraCount = settingsList.Count(s => s.IsEnabled);
// if (EnabledCameraCount == 0)
// {
// ThreadSafeLogger.Log("没有启用的相机。");
// return;
// }
// var deviceList = new HikvisionCamera().FindDevices();
// if (deviceList.Count == 0)
// {
// ThreadSafeLogger.Log("错误:未找到任何相机设备!");
// return;
// }
// int deviceIndex = 0;
// foreach (var setting in settingsList)
// {
// if (!setting.IsEnabled) continue;
// if (deviceIndex >= deviceList.Count)
// {
// ThreadSafeLogger.Log($"警告:相机配置'{setting.Name}'无法分配物理设备,因为设备数量不足。");
// continue;
// }
// // --- 创建相机实例 ---
// var cam = new HikvisionCamera { Name = setting.Name };
// cam.TriggerMode = setting.TriggerMode;
// if (!cam.Open(setting))
// {
// ThreadSafeLogger.Log($"错误:打开相机'{setting.Name}'失败。");
// cam.Dispose();
// continue;
// }
// // --- 设置触发模式 ---
// switch (setting.TriggerMode)
// {
// case TriggerModeType.Continuous:
// cam.SetContinuousMode();
// break;
// case TriggerModeType.Software:
// cam.SetTriggerMode(true);
// break;
// case TriggerModeType.Hardware:
// cam.SetTriggerMode(false);
// break;
// }
// // --- 订阅事件 ---
// cam.ImageAcquired += OnCameraImageAcquired;
// cam.CameraMessage += (sender, msg, err) => ThreadSafeLogger.Log($"{(sender as HikvisionCamera).Name}: {msg}");
// // --- 创建显示窗口 ---
// var displayForm = new FormImageDisplay { Text = setting.Name, CameraName = setting.Name };
// displayForm.OnDisplayEvent += ThreadSafeLogger.Log;
// displayForm.Show(mainForm.MainDockPanel, WeifenLuo.WinFormsUI.Docking.DockState.Document);
// ActiveCameras.Add(setting.Name, cam);
// CameraDisplays.Add(setting.Name, displayForm);
// mainForm.AddCameraToStatusStrip(setting.Name);
// ThreadSafeLogger.Log($"相机'{setting.Name}'初始化成功,分配到物理设备 {deviceIndex}。");
// deviceIndex++;
// }
//}
/// <summary>
/// 准备所有相机硬件、UI窗口和后台处理器但不开始采集。
/// 这是“启动设备”的第一阶段。
/// </summary>
public static void PrepareAll(ProcessConfig config, FrmMain mainForm)
{
// 1. 清理旧资源和UI
mainForm.ClearStatusStrip();
Shutdown();
ThreadSafeLogger.Log("开始准备设备和模型...");
// 2. 初始化检测协调器和AI模型
// 注意YoloModelManager 的 Initialize 现在也应在这里被调用,以确保逻辑集中
YoloModelManager.Initialize(config.ModelSettings);
DetectionCoordinator.Initialize(config.CameraSettings, config.ModelSettings);
// 3. 创建相机硬件实例和UI窗口
var deviceList = new HikvisionCamera().FindDevices();
if (deviceList.Count == 0)
{
ThreadSafeLogger.Log("错误:未找到任何相机设备!");
return;
}
foreach (var setting in config.CameraSettings.Where(s => s.IsEnabled))
{
var cam = new HikvisionCamera { Name = setting.Name, CameraIndex = setting.CameraIndex };
cam.TriggerMode = setting.TriggerMode;
if (!cam.Open(setting))
{
ThreadSafeLogger.Log($"错误:打开相机'{setting.Name}'失败。");
cam.Dispose();
continue;
}
// --- 设置触发模式 ---
switch (setting.TriggerMode)
{
case TriggerModeType.Continuous:
cam.SetContinuousMode();
break;
case TriggerModeType.Software:
cam.SetTriggerMode(true);
break;
case TriggerModeType.Hardware:
cam.SetTriggerMode(false);
break;
}
// --- 订阅事件 ---
cam.ImageAcquired += OnCameraImageAcquired;
var processor = DetectionCoordinator.GetProcessor(cam.CameraIndex);
if (processor != null) { processor.OnProcessingCompleted += Processor_OnProcessingCompleted; }
// --- 创建【但不显示】图像的UI窗口 ---
var originalDisplay = new FormImageDisplay { Text = $"{setting.Name} - 原图", CameraName = setting.Name };
var resultDisplay = new FormImageDisplay { Text = $"{setting.Name} - 结果", CameraName = setting.Name };
originalDisplay.Show(mainForm.MainDockPanel, WeifenLuo.WinFormsUI.Docking.DockState.Document);
resultDisplay.Show(mainForm.MainDockPanel, WeifenLuo.WinFormsUI.Docking.DockState.Document);
// --- 保存引用 ---
ActiveCameras.Add(setting.Name, cam);
OriginalImageDisplays.Add(setting.Name, originalDisplay);
ResultImageDisplays.Add(setting.Name, resultDisplay);
mainForm.AddCameraToStatusStrip(setting.Name);
}
ThreadSafeLogger.Log("所有设备和模型已准备就绪。");
}
/// <summary>
/// 根据配置列表初始化或更新所有相机
/// </summary>
public static void Initialize(ProcessConfig config, FrmMain mainForm)
{
mainForm?.ClearStatusStrip();
// 先停止并释放所有旧的相机
Shutdown();
ThreadSafeLogger.Log("开始应用新的相机配置...");
// 2. 初始化新的检测协调器
DetectionCoordinator.Initialize(config.CameraSettings, config.ModelSettings);
var deviceList = new HikvisionCamera().FindDevices();
if (deviceList.Count == 0)
{
ThreadSafeLogger.Log("错误:未找到任何相机设备!");
return;
}
int deviceIndex = 0;
foreach (var device in config.ModelSettings.Where(s => s.IsEnabled))
{
if (!device.IsEnabled) continue;
mainForm.AddCameraToStatusStrip(device.Name);
}
foreach (var setting in config.CameraSettings.Where(s => s.IsEnabled))
{
if (!setting.IsEnabled) continue;
if (deviceIndex >= deviceList.Count)
{
ThreadSafeLogger.Log($"警告:相机配置'{setting.Name}'无法分配物理设备,因为设备数量不足。");
continue;
}
// --- 创建相机实例 ---
var cam = new HikvisionCamera { Name = setting.Name, CameraIndex = setting.CameraIndex };
cam.TriggerMode = setting.TriggerMode;
if (!cam.Open(setting))
{
ThreadSafeLogger.Log($"错误:打开相机'{setting.Name}'失败。");
cam.Dispose();
continue;
}
// --- 设置触发模式 ---
switch (setting.TriggerMode)
{
case TriggerModeType.Continuous:
cam.SetContinuousMode();
break;
case TriggerModeType.Software:
cam.SetTriggerMode(true);
break;
case TriggerModeType.Hardware:
cam.SetTriggerMode(false);
break;
}
// --- 订阅事件 ---
cam.ImageAcquired += OnCameraImageAcquired;
cam.CameraMessage += (sender, msg, err) => ThreadSafeLogger.Log($"{(sender as HikvisionCamera).Name}: {msg}");
ActiveCameras.Add(setting.Name, cam);
// --- 创建显示窗口 ---
var displayForm = new FormImageDisplay { Text = $"{setting.Name} - 原图", CameraName = setting.Name };
var checkFrm = new FormImageDisplay { Text = $"{setting.Name} - 结果", CameraName = setting.Name };
displayForm.OnDisplayEvent += ThreadSafeLogger.Log;
displayForm.Show(mainForm.MainDockPanel, WeifenLuo.WinFormsUI.Docking.DockState.Document);
checkFrm.Show(mainForm.MainDockPanel, WeifenLuo.WinFormsUI.Docking.DockState.Document);
OriginalImageDisplays.Add(setting.Name, displayForm);
ResultImageDisplays.Add(setting.Name, checkFrm);
mainForm.AddCameraToStatusStrip(setting.Name);
var processor = DetectionCoordinator.GetProcessor(cam.CameraIndex);
if (processor != null)
{
processor.OnProcessingCompleted += Processor_OnProcessingCompleted;
}
ThreadSafeLogger.Log($"相机'{setting.Name}'初始化成功,分配到物理设备 {deviceIndex}。");
deviceIndex++;
}
}
/// <summary>
/// 启动硬件触发模拟器。
/// </summary>
public static void StartHardwareTriggerSimulator()
{
if (IsHardwareTriggerSimulating) return;
_hardwareTriggerSimulator.Interval = TriggerInterval;
_hardwareTriggerSimulator.Start();
IsHardwareTriggerSimulating = true;
ThreadSafeLogger.Log($"硬件触发模拟器已启动,触发间隔: {TriggerInterval} ms。");
}
/// <summary>
/// 停止硬件触发模拟器。
/// </summary>
public static void StopHardwareTriggerSimulator()
{
if (!IsHardwareTriggerSimulating) return;
_hardwareTriggerSimulator.Stop();
IsHardwareTriggerSimulating = false;
ThreadSafeLogger.Log("硬件触发模拟器已停止。");
}
/// <summary>
/// 定时器触发事件。
/// </summary>
private static void OnHardwareTriggerTimerElapsed(object sender, ElapsedEventArgs e)
{
// 遍历所有活动的相机
foreach (var cam in ActiveCameras.Values)
{
// 仅对配置为“硬件触发”模式的相机执行操作
// 重要:我们使用软触发命令(SoftwareTrigger)来“模拟”一个外部硬件信号的到达。
if (cam.TriggerMode == TriggerModeType.Software && cam.IsGrabbing)
{
// ThreadSafeLogger.Log($"模拟硬触发信号,触发相机: {cam.Name}"); // 如果需要详细日志可以取消注释
cam.SoftwareTrigger();
}
}
}
/// <summary>
/// 所有启用的相机开始采集
/// </summary>
public static void StartAll()
{
foreach (var cam in ActiveCameras.Values)
{
cam.StartGrabbing();
}
ThreadSafeLogger.Log("所有相机已开始采集。");
}
//public static void StartDetection()
//{
// if (!IsDetectionRunning)
// {
// IsDetectionRunning = true;
// ThreadSafeLogger.Log("检测已启动,开始统计数据。");
// }
//}
//public static void StopDetection()
//{
// if (IsDetectionRunning)
// {
// IsDetectionRunning = false;
// ThreadSafeLogger.Log("检测已停止。");
// }
//}
/// <summary>
/// 所有启用的相机停止采集
/// </summary>
public static void StopAll()
{
foreach (var cam in ActiveCameras.Values)
{
cam.StopGrabbing();
}
ThreadSafeLogger.Log("所有相机已停止采集。");
}
///// <summary>
///// 停止并释放所有相机资源
///// </summary>
//public static void Shutdown()
//{
// // --- 新增:确保在关闭时停止模拟器并释放资源 ---
// StopHardwareTriggerSimulator();
// //_hardwareTriggerSimulator?.Dispose();
// StopAll();
// foreach (var cam in ActiveCameras.Values)
// {
// cam.Dispose();
// }
// foreach (var display in CameraDisplays.Values)
// {
// display.Close();
// }
// lock (QueueLock)
// {
// while (ProductQueue.Count > 0)
// {
// ProductQueue.Dequeue()?.Dispose();
// }
// }
// ResetProductCounter();
// ActiveCameras.Clear();
// CameraDisplays.Clear();
// ThreadSafeLogger.Log("正在关闭文件日志记录器...");
// ThreadSafeLogger.Log("所有相机资源已释放。");
//}
// Shutdown 方法也简化
public static void Shutdown()
{
// 1. 停止硬件和模拟器
StopAll();
StopHardwareTriggerSimulator();
// 2. 关闭相机实例和窗口
foreach (var cam in ActiveCameras.Values) { cam.Dispose(); }
foreach (var display in OriginalImageDisplays.Values) { display.Close(); }
foreach (var display in ResultImageDisplays.Values) { display.Close(); }
ActiveCameras.Clear();
OriginalImageDisplays.Clear();
ResultImageDisplays.Clear();
// 3. 关闭检测协调器,它会负责清理所有后台线程和队列
DetectionCoordinator.Shutdown();
YoloModelManager.Shutdown();
ThreadSafeLogger.Log("所有相机及协调器资源已释放。");
}
/// <summary>
/// 重置产品计数器的公共方法
/// </summary>
public static void ResetProductCounter()
{
lock (_counterLock)
{
_productCounter = 0;
}
ThreadSafeLogger.Log("产品计数器已重置。");
}
/// <summary>
/// 接收到相机图像时的核心处理逻辑
/// </summary>
//private static void OnCameraImageAcquired(HikvisionCamera sender, Bitmap bmp)
//{
// Bitmap bmpForDisplay =null;
// Bitmap bmpForQueue = null;
// try
// {
// bmpForDisplay?.Dispose();
// bmpForQueue?.Dispose();
// // 1. 为“显示”和“处理队列”创建独立的深克隆副本
// bmpForDisplay = DeepCloneBitmap(bmp, "Display");
// bmpForQueue = DeepCloneBitmap(bmp, "Queue");
// }
// finally
// {
// // 【关键】无论克隆成功与否都必须立即释放事件传递过来的原始bmp。
// // 这是保证没有泄漏的第一道防线。
// bmp?.Dispose();
// }
// // --- 现在我们使用完全独立的副本进行后续操作 ---
// // 4. 将显示副本传递给UI
// if (bmpForDisplay != null && CameraDisplays.TryGetValue(sender.Name, out var display))
// {
// display.UpdateImage(bmpForDisplay);
// }
// else
// {
// // 如果不需要显示,或者显示失败,必须释放掉为它创建的副本
// bmpForDisplay?.Dispose();
// }
// // 5. 将队列副本添加到产品中
// if (bmpForQueue != null)
// {
// lock (QueueLock)
// {
// ProductResult currentProduct;
// bool isNewProductCycle = ProductQueue.Count == 0 || ProductQueue.Last().CapturedImages.ContainsKey(sender.Name);
// if (isNewProductCycle)
// {
// if (ProductQueue.Count > 0 && !ProductQueue.Peek().IsComplete(EnabledCameraCount))
// {
// var orphanedProduct = ProductQueue.Dequeue(); // 从队列头部移除这个不完整的产品
// ThreadSafeLogger.Log($"[警告] 产品 #{orphanedProduct.ProductID} 未能集齐所有相机图像而被丢弃,以防止内存泄漏。");
// orphanedProduct.Dispose(); // 确保释放其占用的所有资源
// }
// long newProductId;
// lock (_counterLock)
// {
// _productCounter++;
// newProductId = _productCounter;
// }
// currentProduct = new ProductResult(newProductId);
// ProductQueue.Enqueue(currentProduct);
// }
// else
// {
// currentProduct = ProductQueue.Last();
// }
// currentProduct.AddImage(sender.Name, bmpForQueue); // bmpForQueue的所有权转移给了产品队列
// while (ProductQueue.Count > 0 && ProductQueue.Peek().IsComplete(EnabledCameraCount))
// {
// // Peek() 查看队头元素但不移除它
// var finishedProduct = ProductQueue.Dequeue(); // Dequeue() 移除队头元素
// ThreadSafeLogger.Log($"产品 #{finishedProduct.ProductID} 已完整,出队处理。");
// // --- 这是您已有的处理逻辑 ---
// bool isOk = new Random().NextDouble() > 0.1;
// string finalResult = isOk ? "OK" : "NG";
// ThreadSafeLogger.Log($"产品 #{finishedProduct.ProductID} 检测结果: {finalResult}");
// if (IsDetectionRunning)
// {
// OnDetectionCompleted?.Invoke(null, new DetectionResultEventArgs(isOk));
// }
// try
// {
// // 确保完成的产品被完全释放
// finishedProduct.Dispose();
// }
// catch (Exception ex)
// {
// ThreadSafeLogger.Log($"[ERROR] 释放产品 #{finishedProduct.ProductID} 资源时出错: {ex.Message}");
// }
// }
// }
// }
//}
// 图像回调方法现在极其简单
private static void OnCameraImageAcquired(HikvisionCamera sender, Bitmap bmp)
{
Bitmap bmpForDisplay = null;
Bitmap bmpForProcessing = null;
try
{
// 1. 为“显示”和“处理”创建两个完全独立的深克隆副本
bmpForDisplay = DeepCloneBitmap(bmp, "Display");
bmpForProcessing = DeepCloneBitmap(bmp, "Processing");
}
finally
{
// 2.无论克隆成功与否都必须立即释放事件传递过来的原始bmp防止泄漏。
bmp?.Dispose();
}
// 分支 A: 将用于显示的副本发送到对应的UI窗口
if (bmpForDisplay != null && OriginalImageDisplays.TryGetValue(sender.Name, out var displayWindow))
{
// displayWindow.UpdateImage 会处理线程安全问题
displayWindow.UpdateImage(bmpForDisplay);
}
else
{
// 如果没有对应的显示窗口,或克隆失败,必须释放为显示创建的副本
bmpForDisplay?.Dispose();
}
// 分支 B: 将用于处理的副本发送到检测协调器的后台队列
if (bmpForProcessing != null)
{
// bmpForProcessing 的所有权在这里被转移给了协调器
DetectionCoordinator.EnqueueImage(sender.CameraIndex, bmpForProcessing);
}
//// 深度克隆图像以确保线程安全
//Bitmap bmpForProcessing = DeepCloneBitmap(bmp, "Processing");
//bmp?.Dispose(); // 立即释放原始图
//// 直接将图像和相机编号交给协调器,无需任何本地处理
//DetectionCoordinator.EnqueueImage(sender.CameraIndex, bmpForProcessing);
}
// 事件处理器
private static void Processor_OnProcessingCompleted(object sender, ProcessingCompletedEventArgs e)
{
// 1. 找到与此相机匹配的相机名称
var cameraEntry = ActiveCameras.FirstOrDefault(kvp => kvp.Value.CameraIndex == e.CameraIndex);
if (cameraEntry.Key == null)
{
e.Dispose(); // 如果找不到接收者,必须释放事件参数中的图像
return;
}
// 2. 找到此相机的结果显示窗口
if (ResultImageDisplays.TryGetValue(cameraEntry.Key, out var resultDisplay))
{
var bmp = ConvertSKImageToBitmap(e.ResultImage);
if (bmp != null)
{
// UpdateImage 会负责克隆并显示,所以这里传递 bmp 即可
resultDisplay.UpdateImage(bmp);
}
}
else
{
// 如果找到了相机但没有对应的结果窗口,也要释放图像
e.Dispose();
}
}
/// <summary>
/// 【将 SkiaSharp.SKImage 安全地转换为 System.Drawing.Bitmap。
/// </summary>
private static Bitmap ConvertSKImageToBitmap(SKImage skImage)
{
if (skImage == null) return null;
try
{
// SKImage -> SKBitmap -> System.Drawing.Bitmap
using (var skBitmap = SKBitmap.FromImage(skImage))
{
// SKBitmap.ToBitmap() 会创建一个新的 Bitmap 对象
return SKBitmapToGdiBitmapFast(skBitmap);
}
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[错误] SKImage to Bitmap 转换失败: {ex.Message}");
return null;
}
}
public static Bitmap SKBitmapToGdiBitmapFast(SKBitmap skBitmap)
{
if (skBitmap == null) throw new ArgumentNullException(nameof(skBitmap));
if (skBitmap.ColorType != SKColorType.Bgra8888 || skBitmap.AlphaType != SKAlphaType.Premul)
throw new ArgumentException("skBitmap must be Bgra8888 + Premul for the fast path.");
int w = skBitmap.Width;
int h = skBitmap.Height;
Bitmap bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb);
var rect = new Rectangle(0, 0, w, h);
var bmpData = bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
try
{
IntPtr srcPtr = skBitmap.GetPixels();
int srcRowBytes = skBitmap.RowBytes;
int dstRowBytes = bmpData.Stride;
int copyBytesPerRow = Math.Min(srcRowBytes, dstRowBytes);
byte[] row = new byte[copyBytesPerRow]; // 复用同一行缓冲区,避免每行分配
for (int y = 0; y < h; y++)
{
IntPtr s = IntPtr.Add(srcPtr, y * srcRowBytes);
IntPtr d = IntPtr.Add(bmpData.Scan0, y * dstRowBytes);
Marshal.Copy(s, row, 0, copyBytesPerRow);
Marshal.Copy(row, 0, d, copyBytesPerRow);
}
}
finally
{
bmp.UnlockBits(bmpData);
}
return bmp;
}
/// <summary>
/// 对Bitmap进行真正的深度克隆确保内存完全独立。
/// 这是解决跨线程GDI+问题的最可靠方法。
/// </summary>
/// <param name="source">源Bitmap对象。</param>
/// <returns>一个与源图像在内存上完全独立的全新Bitmap对象。</returns>
private static Bitmap DeepCloneBitmap(Bitmap source, string cloneFor)
{
if (source == null) return null;
// 创建一个新的Bitmap对象具有与源相同的尺寸和像素格式
Bitmap clone = new Bitmap(source.Width, source.Height, source.PixelFormat);
// 如果有调色板,复制调色板
if (source.Palette.Entries.Length > 0)
{
clone.Palette = source.Palette;
}
// 锁定源和目标Bitmap的内存区域
var rect = new Rectangle(0, 0, source.Width, source.Height);
BitmapData sourceData = null; //source.LockBits(rect, ImageLockMode.ReadOnly, source.PixelFormat);
BitmapData cloneData = null;//clone.LockBits(rect, ImageLockMode.WriteOnly, clone.PixelFormat);
try
{
sourceData = source.LockBits(rect, ImageLockMode.ReadOnly, source.PixelFormat);
cloneData = clone.LockBits(rect, ImageLockMode.WriteOnly, clone.PixelFormat);
// 计算需要拷贝的字节数
int byteCount = Math.Abs(sourceData.Stride) * source.Height;
byte[] buffer = new byte[byteCount];
// 从源图像拷贝数据到字节数组
Marshal.Copy(sourceData.Scan0, buffer, 0, byteCount);
// 从字节数组拷贝数据到目标图像
Marshal.Copy(buffer, 0, cloneData.Scan0, byteCount);
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[克隆错误] 在 DeepCloneBitmap ({cloneFor}) 中发生异常: {ex.Message}");
// 如果发生错误确保返回null并且释放可能已经创建的clone对象
clone?.Dispose();
return null;
}
finally
{
// 确保即使在发生错误时也能尝试解锁
if (sourceData != null)
{
source.UnlockBits(sourceData);
}
if (cloneData != null)
{
clone.UnlockBits(cloneData);
}
//ThreadSafeLogger.Log($"[克隆完成] 解锁并完成克隆 ({cloneFor}).");
}
return clone;
}
// 触发日志事件的辅助方法
//public static void Log(string message)
//{
// string formattedMessage = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
// // 1. 触发事件更新UI
// OnLogMessage?.Invoke(formattedMessage);
// // 2. 【关键修改】在写入文件前,先获取锁。
// // lock 语句块确保了花括号内的代码在同一时间只能被一个线程执行。
// // 如果另一个线程也想执行这段代码,它必须等待前一个线程执行完毕并释放锁。
// lock (_logLock)
// {
// try
// {
// _logFileWriter?.WriteLine(formattedMessage);
// }
// catch (Exception)
// {
// // 即使有锁也保留try-catch以防万一如磁盘满了等IO问题
// // 可以在这里决定是否要在UI上显示警告。
// }
// }
//}
}
}

View File

@@ -0,0 +1,211 @@
using Check.Main.Common;
using Check.Main.Infer;
using SkiaSharp;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Drawing.Imaging;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using YoloDotNet.Extensions;
namespace Check.Main.Camera
{
public class CameraProcessor : IDisposable
{
private readonly int _cameraIndex;
private readonly int _modeId;
// private readonly ModelSettings _model;
private readonly BlockingCollection<ImageData> _imageQueue = new BlockingCollection<ImageData>();
private readonly Thread _workerThread;
private volatile bool _isRunning = false;
private long _imageCounter = 0;
private readonly object _counterLock = new object(); // 用于线程安全地重置计数器
public event EventHandler<ProcessingCompletedEventArgs> OnProcessingCompleted;
public CameraProcessor(int cameraIndex, int modelId)//, ModelSettings model
{
_cameraIndex = cameraIndex;
_modeId = modelId;
//_model = model;
_workerThread = new Thread(ProcessQueue) { IsBackground = true, Name = $"Cam_{_cameraIndex}_Processor" };
}
public void Start()
{
_isRunning = true;
_workerThread.Start();
}
public void EnqueueImage(Bitmap bmp)
{
if (!_isRunning)
{
bmp?.Dispose();
return;
}
_imageCounter++;
_imageQueue.Add(new ImageData(_imageCounter, _cameraIndex, bmp));
}
private void ProcessQueue()
{
// 从模型管理器获取此线程专属的YOLO模型
var yoloModel = YoloModelManager.GetModel(_modeId);
if (yoloModel == null)
{
ThreadSafeLogger.Log($"[错误] 相机 #{_modeId} 无法获取对应的YOLO模型处理线程已中止。");
return; // 如果没有模型,此线程无法工作
}
while (_isRunning)
{
try
{
// 阻塞式地从队列中取出图像,如果队列为空则等待
ImageData data = _imageQueue.Take();
using (data)
{
//SKImage resultSkImage = null; // 用于存储最终绘制好结果的图像
using (var skImage = ConvertBitmapToSKImage(data.Image)) // 转换图像格式并管理其生命周期
{
if (skImage == null) continue;
var predictions = yoloModel.RunObjectDetection(skImage);
// 模拟模型处理
//Thread.Sleep(50); // 模拟耗时
//bool isOk = new Random().NextDouble() > 0.1;
//string result = isOk ? "OK" : "NG";
string result = predictions.Any() ? "NG" : "OK";
ThreadSafeLogger.Log($"相机 #{_cameraIndex} 处理产品 #{data.ProductId},检测到 {predictions.Count} 个目标,结果: {result}");
// 将处理结果交给协调器进行组装
DetectionCoordinator.AssembleProduct(data, result);
if (OnProcessingCompleted != null)
{
using (var resultSkImage = skImage.Draw(predictions))
{
// 4. 触发事件,将绘制好的 resultSkImage 传递出去
// 所有权在这里被转移
OnProcessingCompleted?.Invoke(this, new ProcessingCompletedEventArgs(
_cameraIndex,
data.ProductId,
resultSkImage
));
}
}
}
}
}
catch (InvalidOperationException)
{
// 当调用 Stop 时,会 CompleteAdding 队列Take 会抛出此异常,是正常退出流程
break;
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[ERROR] 相机 #{_cameraIndex} 处理线程异常: {ex.Message}");
}
}
}
/// <summary>
/// 将 System.Drawing.Bitmap 安全地转换为 SkiaSharp.SKImage。
/// </summary>
private SKImage ConvertBitmapToSKImage(Bitmap bitmap)
{
if (bitmap == null) return null;
try
{
// 使用 using 确保 SKBitmap 被正确释放
using (var skBitmap = ToSKBitmapFast(bitmap))
{
return SKImage.FromBitmap(skBitmap);
}
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[错误] Bitmap to SKImage 转换失败: {ex.Message}");
return null;
}
}
public static SKBitmap ToSKBitmapFast(Bitmap bitmap)
{
// 确保是 32bppArgbBGRA 内存布局)
Bitmap src = bitmap;
if (bitmap.PixelFormat != PixelFormat.Format32bppArgb)
{
src = new Bitmap(bitmap.Width, bitmap.Height, PixelFormat.Format32bppArgb);
using (Graphics g = Graphics.FromImage(src))
{
g.DrawImage(bitmap, 0, 0, bitmap.Width, bitmap.Height);
}
}
var rect = new Rectangle(0, 0, src.Width, src.Height);
var bmpData = src.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
try
{
var info = new SKImageInfo(src.Width, src.Height, SKColorType.Bgra8888, SKAlphaType.Premul);
var skBitmap = new SKBitmap(info);
IntPtr destPtr = skBitmap.GetPixels(); // 目标内存
IntPtr srcRowPtr = bmpData.Scan0; // 源行首
int srcStride = bmpData.Stride;
int destRowBytes = skBitmap.RowBytes;
int copyBytesPerRow = Math.Min(srcStride, destRowBytes);
// 使用一次分配的缓冲区并用 Marshal.Copy 行拷贝(不分配每行)
byte[] row = new byte[copyBytesPerRow];
for (int y = 0; y < src.Height; y++)
{
IntPtr s = IntPtr.Add(bmpData.Scan0, y * srcStride);
IntPtr d = IntPtr.Add(destPtr, y * destRowBytes);
Marshal.Copy(s, row, 0, copyBytesPerRow);
Marshal.Copy(row, 0, d, copyBytesPerRow);
}
return skBitmap;
}
finally
{
src.UnlockBits(bmpData);
if (!ReferenceEquals(src, bitmap))
{
src.Dispose();
}
}
}
public void Stop()
{
_isRunning = false;
// 解除阻塞,让线程可以检查 _isRunning 标志并退出
_imageQueue.CompleteAdding();
_workerThread.Join(500); // 等待线程结束
}
/// <summary>
/// 线程安全地重置该相机的图像计数器。
/// </summary>
public void ResetCounter()
{
lock (_counterLock)
{
_imageCounter = 0;
}
}
// 别忘了在 DetectionCoordinator 中添加一个辅助方法来获取处理器
public void Dispose()
{
Stop();
_imageQueue.Dispose();
}
}
}

View File

@@ -0,0 +1,182 @@
using Check.Main.Common;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing.Design;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms.Design;
namespace Check.Main.Camera
{
// 相机触发方式枚举
public enum TriggerModeType
{
[Description("连续采集")]
Continuous,
[Description("软件触发")]
Software,
[Description("硬件触发")]
Hardware
}
public enum CheckType
{
[Description("传统算法")]
Traditional,
[Description("深度学习")]
DeepLearning
}
/// <summary>
/// 相机配置信息类用于PropertyGrid显示和编辑
/// </summary>
public class CameraSettings : INotifyPropertyChanged, ICloneable
{
// 1. 实现 INotifyPropertyChanged 接口所要求的事件
public event PropertyChangedEventHandler PropertyChanged;
private int _cameraIndex = 0;
private string _name = "Camera-1";
private string _ipAddress = "192.168.1.100";
private string _ipDeviceAddress = "192.168.1.101";
private TriggerModeType _triggerMode = TriggerModeType.Continuous;
private bool _isEnabled = true;
private CheckType _checkType = CheckType.DeepLearning;
private int _modelID = 0;
[Category("基本信息"), DisplayName("相机编号"), Description("相机的唯一标识符,用于与模型编号对应。")]
public int CameraIndex
{
get => _cameraIndex;
set
{
if (_cameraIndex != value)
{
_cameraIndex = value;
OnPropertyChanged();
}
}
}
[Category("基本信息"), DisplayName("相机名称"), Description("给相机起一个唯一的别名")]
public string Name
{
get => _name;
set
{
// 2. 在setter中检查值是否真的改变了
if (_name != value)
{
_name = value;
// 3. 如果改变了就调用通知方法广播“Name”属性已变更的消息
OnPropertyChanged();
}
}
}
[Category("网络信息"), DisplayName("相机IP地址"), Description("相机的IP地址GigE相机需要")]
public string IPAddress
{
get => _ipAddress;
set
{
if (_ipAddress != value)
{
_ipAddress = value;
OnPropertyChanged();
}
}
}
[Category("网络信息"), DisplayName("上位机IP地址"), Description("相机对应的上位机的IP地址GigE相机需要")]
public string IPDeviceAddress
{
get => _ipDeviceAddress;
set
{
if (_ipDeviceAddress != value)
{
_ipDeviceAddress = value;
OnPropertyChanged();
}
}
}
[Category("采集控制"), DisplayName("触发模式"), Description("设置相机的图像采集触发方式")]
[TypeConverter(typeof(EnumDescriptionTypeConverter))]
public TriggerModeType TriggerMode
{
get => _triggerMode;
set
{
if (_triggerMode != value)
{
_triggerMode = value;
OnPropertyChanged();
}
}
}
[Category("采集控制"), DisplayName("是否启用"), Description("是否在程序启动时初始化并使用此相机")]
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
[Category("检测配置"), DisplayName("检测类型"), Description("使用传统算法或深度学习")]
[TypeConverter(typeof(EnumDescriptionTypeConverter))]
public CheckType CheckType
{
get => _checkType;
set
{
if (_checkType != value)
{
_checkType = value;
OnPropertyChanged();
}
}
}
[Category("基本信息"), DisplayName("模型编号"), Description("相机调用的模型编号")]
[TypeConverter(typeof(ModelSelectionConverter))]
public int ModelID
{
get => _modelID;
set
{
if (_modelID != value)
{
_modelID = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 4. 触发 PropertyChanged 事件的辅助方法
/// [CallerMemberName] 特性是一个“语法糖”,它会自动获取调用此方法的属性的名称。
/// 例如在Name属性的setter中调用OnPropertyChanged()C#编译器会自动传入"Name"作为参数。
/// </summary>
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public object Clone()
{
// MemberwiseClone() 会创建一个新对象,并将当前对象的所有非静态字段的值
// 复制到新对象中。对于值类型,这是值的拷贝;对于引用类型,这是引用的拷贝。
// 在本类中,这已经足够了。
return this.MemberwiseClone();
}
}
}

View File

@@ -0,0 +1,502 @@
using MvCamCtrl.NET;
using Sunny.UI;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Check.Main.Camera
{
public class HikvisionCamera : IDisposable
{
[DllImport("kernel32.dll", EntryPoint = "RtlMoveMemory", SetLastError = false)]
private static extern void CopyMemory(IntPtr dest, IntPtr src, uint count);
// 事件委托
public delegate void ImageAcquiredEventHandler(HikvisionCamera sender, Bitmap bmp);
// public delegate void ImageAcquiredEventHandler(object sender, Bitmap bmp);
public delegate void CameraMessageEventHandler(object sender, string message, int errorCode = 0);
// 事件
public event ImageAcquiredEventHandler ImageAcquired;
public event CameraMessageEventHandler CameraMessage;
// 私有成员变量
private MyCamera m_MyCamera;
private MyCamera.MV_CC_DEVICE_INFO_LIST m_stDeviceList;
private bool m_bGrabbing = false;
private Thread m_hReceiveThread = null;
private Bitmap m_bitmap = null;
private IntPtr m_ConvertDstBuf = IntPtr.Zero;
private uint m_nConvertDstBufLen = 0;
public string Name { get; set; }
/// <summary>
/// 由外部逻辑分配的、唯一的相机软件编号。
/// 主要用作图像路由和与逻辑处理器匹配的键。
/// </summary>
public int CameraIndex { get; set; }
/// <summary>
/// 相机是否已经打开
/// </summary>
public bool IsOpen { get; private set; } = false;
public TriggerModeType TriggerMode { get; internal set; } // internal set 保证只有 CameraManager 能设置它
/// <summary>
/// 相机是否正在采集
/// </summary>
public bool IsGrabbing => m_bGrabbing;
public HikvisionCamera()
{
// 初始化SDK
MyCamera.MV_CC_Initialize_NET();
}
/// <summary>
/// 查找所有可用的相机设备
/// </summary>
/// <returns>设备名称列表</returns>
public List<string> FindDevices()
{
var deviceList = new List<string>();
m_stDeviceList = new MyCamera.MV_CC_DEVICE_INFO_LIST();
int nRet = MyCamera.MV_CC_EnumDevices_NET(MyCamera.MV_GIGE_DEVICE | MyCamera.MV_USB_DEVICE, ref m_stDeviceList);
if (nRet != MyCamera.MV_OK)
{
OnCameraMessage("设备枚举失败!", nRet);
return deviceList;
}
for (int i = 0; i < m_stDeviceList.nDeviceNum; i++)
{
var device = (MyCamera.MV_CC_DEVICE_INFO)Marshal.PtrToStructure(m_stDeviceList.pDeviceInfo[i], typeof(MyCamera.MV_CC_DEVICE_INFO));
if (device.nTLayerType == MyCamera.MV_GIGE_DEVICE)
{
var gigeInfo = (MyCamera.MV_GIGE_DEVICE_INFO_EX)MyCamera.ByteToStruct(device.SpecialInfo.stGigEInfo, typeof(MyCamera.MV_GIGE_DEVICE_INFO_EX));
if (!string.IsNullOrEmpty(Encoding.Default.GetString(gigeInfo.chUserDefinedName).TrimEnd('\0')))
deviceList.Add("GEV: " + gigeInfo.chUserDefinedName + " (" + gigeInfo.chSerialNumber + ")");
else
deviceList.Add("GEV: " + gigeInfo.chManufacturerName + " " + gigeInfo.chModelName + " (" + gigeInfo.chSerialNumber + ")");
}
else if (device.nTLayerType == MyCamera.MV_USB_DEVICE)
{
var usbInfo = (MyCamera.MV_USB3_DEVICE_INFO_EX)MyCamera.ByteToStruct(device.SpecialInfo.stUsb3VInfo, typeof(MyCamera.MV_USB3_DEVICE_INFO_EX));
if (!string.IsNullOrEmpty(Encoding.Default.GetString(usbInfo.chUserDefinedName).TrimEnd('\0')))
deviceList.Add("U3V: " + usbInfo.chUserDefinedName + " (" + usbInfo.chSerialNumber + ")");
else
deviceList.Add("U3V: " + usbInfo.chManufacturerName + " " + usbInfo.chModelName + " (" + usbInfo.chSerialNumber + ")");
}
}
return deviceList;
}
/// <summary>
/// 根据索引打开相机
/// </summary>
/// <param name="index">设备索引</param>
/// <returns>成功返回true否则返回false</returns>
public bool Open(CameraSettings camConfig)
{
string ipCam = "";
string ipDevice = "";
FindDevices();
if (camConfig.IsEnabled == true)
{
ipCam = camConfig.IPAddress;
ipDevice = camConfig.IPDeviceAddress;
}
try
{
if (m_stDeviceList.nDeviceNum == 0 || string.IsNullOrEmpty(camConfig.IPAddress))
{
OnCameraMessage("没有找到设备或索引无效。", -1);
return false;
}
if (IsOpen)
{
OnCameraMessage("相机已经打开。", 0);
return true;
}
MyCamera.MV_CC_DEVICE_INFO device = new MyCamera.MV_CC_DEVICE_INFO();
device.nTLayerType = MyCamera.MV_GIGE_DEVICE;
MyCamera.MV_GIGE_DEVICE_INFO stGigEDev = new MyCamera.MV_GIGE_DEVICE_INFO();
var parts = ipCam.Split('.');
int nIp1 = Convert.ToInt32(parts[0]);
int nIp2 = Convert.ToInt32(parts[1]);
int nIp3 = Convert.ToInt32(parts[2]);
int nIp4 = Convert.ToInt32(parts[3]);
stGigEDev.nCurrentIp = (uint)((nIp1 << 24) | (nIp2 << 16) | (nIp3 << 8) | nIp4);
parts = ipDevice.Split('.');
nIp1 = Convert.ToInt32(parts[0]);
nIp2 = Convert.ToInt32(parts[1]);
nIp3 = Convert.ToInt32(parts[2]);
nIp4 = Convert.ToInt32(parts[3]);
stGigEDev.nNetExport = (uint)((nIp1 << 24) | (nIp2 << 16) | (nIp3 << 8) | nIp4);
IntPtr stGigeInfoPtr = Marshal.AllocHGlobal(216);
Marshal.StructureToPtr(stGigEDev, stGigeInfoPtr, false);
device.SpecialInfo.stGigEInfo = new Byte[540];
Marshal.Copy(stGigeInfoPtr, device.SpecialInfo.stGigEInfo, 0, 540);
//释放内存空间
Marshal.FreeHGlobal(stGigeInfoPtr);
//var device = (MyCamera.MV_CC_DEVICE_INFO)Marshal.PtrToStructure(m_stDeviceList.pDeviceInfo[index], typeof(MyCamera.MV_CC_DEVICE_INFO));
m_MyCamera = new MyCamera();
int nRet = m_MyCamera.MV_CC_CreateDevice_NET(ref device);
if (nRet != MyCamera.MV_OK)
{
OnCameraMessage("创建设备句柄失败!", nRet);
return false;
}
nRet = m_MyCamera.MV_CC_OpenDevice_NET();
if (nRet != MyCamera.MV_OK)
{
OnCameraMessage("打开设备失败!", nRet);
m_MyCamera.MV_CC_DestroyDevice_NET();
return false;
}
// 探测网络最佳包大小(只对GigE相机有效)
if (device.nTLayerType == MyCamera.MV_GIGE_DEVICE)
{
int nPacketSize = m_MyCamera.MV_CC_GetOptimalPacketSize_NET();
if (nPacketSize > 0)
{
m_MyCamera.MV_CC_SetIntValueEx_NET("GevSCPSPacketSize", nPacketSize);
}
}
// 默认设置为连续模式
SetContinuousMode();
IsOpen = true;
OnCameraMessage("相机打开成功。", 0);
return true;
}
catch (Exception ex)
{
OnCameraMessage("相机打开失败。"+ ex.ToString(), 0);
return false;
}
}
/// <summary>
/// 关闭相机
/// </summary>
public void Close()
{
if (!IsOpen) return;
if (m_bGrabbing)
{
StopGrabbing();
}
m_MyCamera.MV_CC_CloseDevice_NET();
m_MyCamera.MV_CC_DestroyDevice_NET();
IsOpen = false;
m_MyCamera = null;
OnCameraMessage("相机已关闭。", 0);
}
/// <summary>
/// 开始采集图像
/// </summary>
/// <returns>成功返回true</returns>
public bool StartGrabbing()
{
if (!IsOpen || m_bGrabbing)
{
OnCameraMessage(m_bGrabbing ? "已经在采集中。" : "相机未打开。", -1);
return false;
}
// 取图前的必要操作
if (NecessaryOperBeforeGrab() != MyCamera.MV_OK)
{
return false;
}
int nRet = m_MyCamera.MV_CC_StartGrabbing_NET();
if (nRet != MyCamera.MV_OK)
{
OnCameraMessage("开始采集失败!", nRet);
return false;
}
m_bGrabbing = true;
m_hReceiveThread = new Thread(ReceiveThreadProcess);
m_hReceiveThread.IsBackground = true;
m_hReceiveThread.Start();
OnCameraMessage("开始采集。", 0);
return true;
}
/// <summary>
/// 停止采集图像
/// </summary>
public void StopGrabbing()
{
if (!m_bGrabbing) return;
m_bGrabbing = false;
if (m_hReceiveThread != null && m_hReceiveThread.IsAlive)
{
m_hReceiveThread.Join(1000); // 等待线程退出
}
int nRet = m_MyCamera.MV_CC_StopGrabbing_NET();
if (nRet != MyCamera.MV_OK)
{
OnCameraMessage("停止采集失败!", nRet);
}
OnCameraMessage("停止采集。", 0);
}
/// <summary>
/// 设置为连续采集模式
/// </summary>
public void SetContinuousMode()
{
if (!IsOpen) return;
m_MyCamera.MV_CC_SetEnumValue_NET("AcquisitionMode", (uint)MyCamera.MV_CAM_ACQUISITION_MODE.MV_ACQ_MODE_CONTINUOUS);
m_MyCamera.MV_CC_SetEnumValue_NET("TriggerMode", (uint)MyCamera.MV_CAM_TRIGGER_MODE.MV_TRIGGER_MODE_OFF);
OnCameraMessage("已设置为连续模式。", 0);
}
/// <summary>
/// 设置为触发模式
/// </summary>
/// <param name="isSoftwareTrigger">true为软触发, false为硬触发(Line0)</param>
public void SetTriggerMode(bool isSoftwareTrigger)
{
if (!IsOpen) return;
m_MyCamera.MV_CC_SetEnumValue_NET("AcquisitionMode", (uint)MyCamera.MV_CAM_ACQUISITION_MODE.MV_ACQ_MODE_CONTINUOUS); // 触发模式也需要在Continuous模式下
m_MyCamera.MV_CC_SetEnumValue_NET("TriggerMode", (uint)MyCamera.MV_CAM_TRIGGER_MODE.MV_TRIGGER_MODE_ON);
if (isSoftwareTrigger)
{
m_MyCamera.MV_CC_SetEnumValue_NET("TriggerSource", (uint)MyCamera.MV_CAM_TRIGGER_SOURCE.MV_TRIGGER_SOURCE_SOFTWARE);
OnCameraMessage("已设置为软触发模式。", 0);
}
else
{
m_MyCamera.MV_CC_SetEnumValue_NET("TriggerSource", (uint)MyCamera.MV_CAM_TRIGGER_SOURCE.MV_TRIGGER_SOURCE_LINE0);
OnCameraMessage("已设置为硬触发(Line0)模式。", 0);
}
}
/// <summary>
/// 执行一次软触发
/// </summary>
/// <returns>成功返回true</returns>
public bool SoftwareTrigger()
{
if (!IsOpen || !m_bGrabbing)
{
OnCameraMessage("请先打开相机并开始采集。", -1);
return false;
}
int nRet = m_MyCamera.MV_CC_SetCommandValue_NET("TriggerSoftware");
if (nRet != MyCamera.MV_OK)
{
OnCameraMessage("软触发失败!", nRet);
return false;
}
return true;
}
/// <summary>
/// 图像接收线程
/// </summary>
private void ReceiveThreadProcess()
{
MyCamera.MV_FRAME_OUT stFrameInfo = new MyCamera.MV_FRAME_OUT();
var stConvertParam = new MyCamera.MV_PIXEL_CONVERT_PARAM();
while (m_bGrabbing)
{
int nRet = m_MyCamera.MV_CC_GetImageBuffer_NET(ref stFrameInfo, 1000);
if (nRet == MyCamera.MV_OK)
{
Bitmap bmpForEvent = null; // 在 try 外部声明
try
{
// 初始化转换参数
stConvertParam.nWidth = stFrameInfo.stFrameInfo.nWidth;
stConvertParam.nHeight = stFrameInfo.stFrameInfo.nHeight;
stConvertParam.pSrcData = stFrameInfo.pBufAddr;
stConvertParam.nSrcDataLen = stFrameInfo.stFrameInfo.nFrameLen;
stConvertParam.enSrcPixelType = stFrameInfo.stFrameInfo.enPixelType;
stConvertParam.pDstBuffer = m_ConvertDstBuf;
stConvertParam.nDstBufferSize = m_nConvertDstBufLen;
// 创建用于事件的Bitmap它的格式与类成员m_bitmap一致
bmpForEvent = new Bitmap((int)stConvertParam.nWidth, (int)stConvertParam.nHeight, m_bitmap.PixelFormat);
// 判断并设置目标像素格式
if (bmpForEvent.PixelFormat == PixelFormat.Format8bppIndexed)
{
stConvertParam.enDstPixelType = MyCamera.MvGvspPixelType.PixelType_Gvsp_Mono8;
}
else
{
stConvertParam.enDstPixelType = MyCamera.MvGvspPixelType.PixelType_Gvsp_BGR8_Packed;
}
// 执行像素格式转换
nRet = m_MyCamera.MV_CC_ConvertPixelType_NET(ref stConvertParam);
if (nRet == MyCamera.MV_OK)
{
// 转换成功,锁定位图内存并拷贝数据
BitmapData bmpData = bmpForEvent.LockBits(new Rectangle(0, 0, stConvertParam.nWidth, stConvertParam.nHeight), ImageLockMode.WriteOnly, bmpForEvent.PixelFormat);
// 使用更健壮的逐行拷贝
int pixelSize = (bmpForEvent.PixelFormat == PixelFormat.Format8bppIndexed) ? 1 : 3;
for (int i = 0; i < bmpData.Height; ++i)
{
IntPtr pDst = new IntPtr(bmpData.Scan0.ToInt64() + i * bmpData.Stride);
IntPtr pSrc = new IntPtr(m_ConvertDstBuf.ToInt64() + i * stConvertParam.nWidth * pixelSize);
CopyMemory(pDst, pSrc, (uint)(stConvertParam.nWidth * pixelSize));
}
bmpForEvent.UnlockBits(bmpData);
// 触发图像采集事件,转移 bmpForEvent 的所有权
ImageAcquired?.Invoke(this, bmpForEvent);
// 【关键】所有权已转移将本地引用设为null防止它在finally块中被错误地释放
bmpForEvent = null;
}
// 如果转换失败,不执行任何操作,让 finally 块来处理 bmpForEvent 的释放
}
finally
{
// 【关键】如果 bmpForEvent 不为 null意味着它被成功创建
// 但没有被成功传递给事件处理器(可能因为转换失败或发生其他异常),
// 在这里必须将其释放。
bmpForEvent?.Dispose();
// 无论成功与否都必须释放相机SDK的内部图像缓存
m_MyCamera.MV_CC_FreeImageBuffer_NET(ref stFrameInfo);
}
}
else
{
// 超时,继续等待
Thread.Sleep(5);
}
}
}
/// <summary>
/// 取图前的内存和参数准备
/// </summary>
private int NecessaryOperBeforeGrab()
{
var stWidth = new MyCamera.MVCC_INTVALUE_EX();
int nRet = m_MyCamera.MV_CC_GetIntValueEx_NET("Width", ref stWidth);
if (nRet != MyCamera.MV_OK) { OnCameraMessage("获取宽度失败!", nRet); return nRet; }
var stHeight = new MyCamera.MVCC_INTVALUE_EX();
nRet = m_MyCamera.MV_CC_GetIntValueEx_NET("Height", ref stHeight);
if (nRet != MyCamera.MV_OK) { OnCameraMessage("获取高度失败!", nRet); return nRet; }
var stPixelFormat = new MyCamera.MVCC_ENUMVALUE();
nRet = m_MyCamera.MV_CC_GetEnumValue_NET("PixelFormat", ref stPixelFormat);
if (nRet != MyCamera.MV_OK) { OnCameraMessage("获取像素格式失败!", nRet); return nRet; }
PixelFormat bitmapPixelFormat;
// 判断当前像素格式是否为Mono
bool isMono = IsMono(stPixelFormat.nCurValue);
if (isMono)
{
bitmapPixelFormat = PixelFormat.Format8bppIndexed;
m_nConvertDstBufLen = (uint)(stWidth.nCurValue * stHeight.nCurValue);
}
else
{
bitmapPixelFormat = PixelFormat.Format24bppRgb;
m_nConvertDstBufLen = (uint)(stWidth.nCurValue * stHeight.nCurValue * 3);
}
// 分配转换后的图像数据缓存
if (m_ConvertDstBuf != IntPtr.Zero) Marshal.FreeHGlobal(m_ConvertDstBuf);
m_ConvertDstBuf = Marshal.AllocHGlobal((int)m_nConvertDstBufLen);
// 创建Bitmap对象
if (m_bitmap != null) m_bitmap.Dispose();
m_bitmap = new Bitmap((int)stWidth.nCurValue, (int)stHeight.nCurValue, bitmapPixelFormat);
// 如果是Mono8格式设置调色板
if (isMono)
{
ColorPalette palette = m_bitmap.Palette;
for (int i = 0; i < 256; i++) palette.Entries[i] = Color.FromArgb(i, i, i);
m_bitmap.Palette = palette;
}
return MyCamera.MV_OK;
}
private bool IsMono(uint enPixelType)
{
switch (enPixelType)
{
case (uint)MyCamera.MvGvspPixelType.PixelType_Gvsp_Mono8:
case (uint)MyCamera.MvGvspPixelType.PixelType_Gvsp_Mono10:
case (uint)MyCamera.MvGvspPixelType.PixelType_Gvsp_Mono10_Packed:
case (uint)MyCamera.MvGvspPixelType.PixelType_Gvsp_Mono12:
case (uint)MyCamera.MvGvspPixelType.PixelType_Gvsp_Mono12_Packed:
return true;
default:
return false;
}
}
/// <summary>
/// 触发相机消息事件
/// </summary>
private void OnCameraMessage(string message, int errorCode = 0)
{
CameraMessage?.Invoke(this, message, errorCode);
}
/// <summary>
/// 释放资源
/// </summary>
public void Dispose()
{
Close();
if (m_ConvertDstBuf != IntPtr.Zero)
{
Marshal.FreeHGlobal(m_ConvertDstBuf);
m_ConvertDstBuf = IntPtr.Zero;
}
if (m_bitmap != null)
{
m_bitmap.Dispose();
m_bitmap = null;
}
MyCamera.MV_CC_Finalize_NET();
}
}
}

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DockPanelSuite" Version="3.1.1" />
<PackageReference Include="DockPanelSuite.ThemeVS2015" Version="3.1.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NPOI" Version="2.7.4" />
<PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.10.0.20241108" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.10.0.20241108" />
<PackageReference Include="SkiaSharp" Version="3.116.1" />
<PackageReference Include="SunnyUI" Version="3.8.7" />
<PackageReference Include="YoloDotNet" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="HslCommunication">
<HintPath>..\..\..\HslCommunication-master\HslCommunication.dll</HintPath>
</Reference>
<Reference Include="MvCameraControl.Net">
<HintPath>C:\Program Files (x86)\MVS\Development\DotNet\win64\MvCameraControl.Net.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,124 @@
using Check.Main.Camera;
using Check.Main.Dispatch;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Serialization;
namespace Check.Main.Common
{
/// <summary>
/// 静态全局配置管理器,作为配置数据的“单一数据源”。
/// 负责加载、保存和通知配置变更。
/// </summary>
public static class ConfigurationManager
{
// private static readonly string _configFilePath = Path.Combine(Application.StartupPath, "main_config.xml");
// private static ProcessConfig _currentConfig;
// private static readonly object _lock = new object();
// /// <summary>
// /// 当配置通过 SaveChanges() 方法保存后触发。
// /// 其他模块(如 FormControlPanel可以订阅此事件以响应配置变更。
// /// </summary>
// public static event Action OnConfigurationChanged;
// /// <summary>
// /// 静态构造函数在类首次被访问时自动执行,确保配置只被加载一次。
// /// </summary>
// static ConfigurationManager()
// {
// Load();
// }
// /// <summary>
// /// 获取对当前配置对象的直接引用。
// /// PropertyGrid 可以直接绑定到这个对象进行编辑。
// /// </summary>
// /// <returns>当前的 ProcessConfig 实例。</returns>
// public static ProcessConfig GetCurrentConfig()
// {
// return _currentConfig;
// }
// /// <summary>
// /// 从 XML 文件加载配置。如果文件不存在或加载失败,则创建一个新的默认配置。
// /// </summary>
// private static void Load()
// {
// lock (_lock)
// {
// if (File.Exists(_configFilePath))
// {
// try
// {
// XmlSerializer serializer = new XmlSerializer(typeof(ProcessConfig));
// using (var fs = new FileStream(_configFilePath, FileMode.Open, FileAccess.Read))
// {
// _currentConfig = (ProcessConfig)serializer.Deserialize(fs);
// ThreadSafeLogger.Log("主配置文件加载成功。");
// }
// }
// catch (Exception ex)
// {
// ThreadSafeLogger.Log("加载主配置失败: " + ex.Message + " 将使用默认配置。");
// _currentConfig = new ProcessConfig();
// }
// }
// else
// {
// ThreadSafeLogger.Log("未找到主配置文件,将创建新的默认配置。");
// _currentConfig = new ProcessConfig();
// }
// }
// }
// /// <summary>
// /// 将当前配置保存到文件,并通知所有监听者配置已更改。
// /// 这是响应UI变化的推荐方法。
// /// </summary>
// public static void SaveChanges()
// {
// lock (_lock)
// {
// try
// {
// XmlSerializer serializer = new XmlSerializer(typeof(ProcessConfig));
// using (var fs = new FileStream(_configFilePath, FileMode.Create, FileAccess.Write))
// {
// serializer.Serialize(fs, _currentConfig);
// }
// }
// catch (Exception ex)
// {
// ThreadSafeLogger.Log("保存主配置失败: " + ex.Message);
// }
// }
// // 在锁之外触发事件,以避免监听者中的代码导致死锁
// ThreadSafeLogger.Log("配置已保存,正在触发 OnConfigurationChanged 事件...");
// OnConfigurationChanged?.Invoke();
// }
// 不再自己管理配置,而是直接从 ProductManager 获取
public static ProcessConfig GetCurrentConfig()
{
return ProductManager.CurrentConfig;
}
public static void SaveChanges()
{
ProductManager.SaveCurrentProductConfig();
}
// OnConfigurationChanged 事件现在由 ProductManager.OnProductChanged 替代
// 如果其他地方还依赖这个事件,可以做一个桥接:
public static event Action OnConfigurationChanged
{
add { ProductManager.OnProductChanged += value; }
remove { ProductManager.OnProductChanged -= value; }
}
}
}

View File

@@ -0,0 +1,236 @@
using NPOI.SS.Formula.Functions;
using Sunny.UI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Check.Main.Common
{
/// <summary>
/// 汇川 Easy 系列 PLC 客户端 (Modbus TCP)
/// 支持 D/M/X/Y 地址读写,整数、浮点数、字符串
/// </summary>
public class EasyPlcClient : IDisposable
{
private readonly TcpClient _tcpClient = new();
private NetworkStream _stream;
private ushort _transactionId = 0;
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly string _ip;
private readonly int _port;
private readonly byte _slaveId;
public EasyPlcClient(string ip, int port = 502, byte slaveId = 1)
{
_ip = ip;
_port = port;
_slaveId = slaveId;
}
public async Task ConnectAsync()
{
await _tcpClient.ConnectAsync(_ip, _port);
_stream = _tcpClient.GetStream();
}
#region ---- Modbus ----
private ushort GetTransactionId() =>
unchecked((ushort)Interlocked.Increment(ref Unsafe.As<ushort, int>(ref _transactionId)));
private async Task<byte[]> SendAndReceiveAsync(byte[] pdu, ushort startAddr, ushort length = 1)
{
ushort tid = GetTransactionId();
ushort lengthField = (ushort)(pdu.Length + 1);
byte[] mbap = {
(byte)(tid >> 8), (byte)tid,
0x00, 0x00, // Protocol = 0
(byte)(lengthField >> 8), (byte)lengthField,
_slaveId
};
byte[] adu = new byte[mbap.Length + pdu.Length];
Buffer.BlockCopy(mbap, 0, adu, 0, mbap.Length);
Buffer.BlockCopy(pdu, 0, adu, mbap.Length, pdu.Length);
await _lock.WaitAsync();
try
{
await _stream.WriteAsync(adu, 0, adu.Length);
byte[] header = new byte[7];
await _stream.ReadAsync(header, 0, 7);
int respLength = (header[4] << 8) + header[5];
byte[] resp = new byte[respLength - 1];
await _stream.ReadAsync(resp, 0, resp.Length);
return resp;
}
finally { _lock.Release(); }
}
#endregion
#region ---- ----
private void ParseAddress(string address, out string area, out ushort offset)
{
var letters = Regex.Match(address, @"^[A-Za-z]+").Value.ToUpper();
var digits = Regex.Match(address, @"\d+").Value;
if (string.IsNullOrEmpty(letters) || string.IsNullOrEmpty(digits))
throw new ArgumentException($"地址 {address} 无效");
area = letters;
offset = (ushort)(ushort.Parse(digits) - 1);
// 👉 注意:这里的偏移需根据你的 PLC Modbus 地址表调整
// 例如 D 区可能是 40001 起M 区可能是 00001 起
// 目前假设 D -> Holding Register, M/Y -> Coil, X -> Discrete Input
}
#endregion
#region ---- ----
public async Task WriteAsync(string address, int value)
{
ParseAddress(address, out string area, out ushort offset);
switch (area)
{
case "D": // 写寄存器
byte[] pdu = {
0x06,
(byte)(offset >> 8), (byte)offset,
(byte)(value >> 8), (byte)value
};
await SendAndReceiveAsync(pdu, offset);
break;
case "M":
case "Y": // 写单线圈
byte[] coil = {
0x05,
(byte)(offset >> 8), (byte)offset,
(byte)(value != 0 ? 0xFF : 0x00), 0x00
};
await SendAndReceiveAsync(coil, offset);
break;
default:
throw new NotSupportedException($"写 {area} 区未支持");
}
}
public async Task<int> ReadAsync(string address)
{
ParseAddress(address, out string area, out ushort offset);
switch (area)
{
case "D": // Holding Register
byte[] pdu = { 0x03, (byte)(offset >> 8), (byte)offset, 0x00, 0x01 };
var resp = await SendAndReceiveAsync(pdu, offset);
return (resp[1] << 8) + resp[2];
case "M":
case "Y": // Coils
byte[] pdu1 = { 0x01, (byte)(offset >> 8), (byte)offset, 0x00, 0x01 };
var resp1 = await SendAndReceiveAsync(pdu1, offset);
return (resp1[2] & 0x01) != 0 ? 1 : 0;
case "X": // Inputs
byte[] pdu2 = { 0x02, (byte)(offset >> 8), (byte)offset, 0x00, 0x01 };
var resp2 = await SendAndReceiveAsync(pdu2, offset);
return (resp2[2] & 0x01) != 0 ? 1 : 0;
default:
throw new NotSupportedException($"读 {area} 区未支持");
}
}
#endregion
#region ---- (2) ----
public async Task WriteFloatAsync(string address, float value)
{
ParseAddress(address, out string area, out ushort offset);
if (area != "D") throw new NotSupportedException("浮点仅支持 D 区");
byte[] bytes = BitConverter.GetBytes(value);
if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
byte[] pdu = {
0x10,
(byte)(offset >> 8), (byte)offset,
0x00, 0x02,
0x04,
bytes[0], bytes[1], bytes[2], bytes[3]
};
await SendAndReceiveAsync(pdu, offset, 2);
}
public async Task<float> ReadFloatAsync(string address)
{
ParseAddress(address, out string area, out ushort offset);
if (area != "D") throw new NotSupportedException("浮点仅支持 D 区");
byte[] pdu = { 0x03, (byte)(offset >> 8), (byte)offset, 0x00, 0x02 };
var resp = await SendAndReceiveAsync(pdu, offset, 2);
byte[] data = { resp[1], resp[2], resp[3], resp[4] };
if (BitConverter.IsLittleEndian) Array.Reverse(data);
return BitConverter.ToSingle(data, 0);
}
#endregion
#region ---- ----
public async Task WriteStringAsync(string address, string value, int maxLength)
{
ParseAddress(address, out string area, out ushort offset);
if (area != "D") throw new NotSupportedException("字符串仅支持 D 区");
byte[] strBytes = Encoding.ASCII.GetBytes(value);
if (strBytes.Length > maxLength) Array.Resize(ref strBytes, maxLength);
// 每寄存器2字节
int regCount = (strBytes.Length + 1) / 2;
byte[] data = new byte[regCount * 2];
Array.Copy(strBytes, data, strBytes.Length);
byte[] pdu = new byte[7 + data.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(offset >> 8); pdu[2] = (byte)offset;
pdu[3] = (byte)(regCount >> 8); pdu[4] = (byte)regCount;
pdu[5] = (byte)(data.Length);
Buffer.BlockCopy(data, 0, pdu, 6, data.Length);
await SendAndReceiveAsync(pdu, offset, (ushort)regCount);
}
public async Task<string> ReadStringAsync(string address, int length)
{
ParseAddress(address, out string area, out ushort offset);
if (area != "D") throw new NotSupportedException("字符串仅支持 D 区");
int regCount = (length + 1) / 2;
byte[] pdu = { 0x03, (byte)(offset >> 8), (byte)offset, (byte)(regCount >> 8), (byte)regCount };
var resp = await SendAndReceiveAsync(pdu, offset, (ushort)regCount);
byte[] data = new byte[resp[0]];
Array.Copy(resp, 1, data, 0, data.Length);
return Encoding.ASCII.GetString(data).TrimEnd('\0');
}
#endregion
public void Dispose()
{
_stream?.Dispose();
_tcpClient?.Close();
_lock?.Dispose();
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Check.Main.Common
{
public class ImageData : IDisposable
{
public long ProductId { get; }
public int CameraIndex { get; }
public Bitmap Image { get; }
public ImageData(long productId, int cameraIndex, Bitmap image)
{
ProductId = productId;
CameraIndex = cameraIndex;
Image = image;
}
public void Dispose() => Image?.Dispose();
}
// ProductAssembly.cs - 用于组装一个完整的产品
public class ProductAssembly : IDisposable
{
public long ProductId { get; }
private readonly int _expectedCameraCount;
// 使用 ConcurrentDictionary 保证线程安全
private readonly ConcurrentDictionary<int, string> _results = new ConcurrentDictionary<int, string>();
public ProductAssembly(long productId, int expectedCameraCount)
{
ProductId = productId;
_expectedCameraCount = expectedCameraCount;
}
// 添加一个相机的检测结果
public bool AddResult(int cameraIndex, string result)
{
_results.TryAdd(cameraIndex, result);
return _results.Count == _expectedCameraCount;
}
// 获取最终的聚合结果
public string GetFinalResult()
{
// 如果任何一个结果包含 "NG",则最终结果为 "NG"
return _results.Values.Any(r => r.Contains("NG")) ? "NG" : "OK";
}
public void Dispose()
{
// 目前只持有结果字符串,无需释放资源
}
}
}

View File

@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static System.ComponentModel.TypeConverter;
namespace Check.Main.Common
{
/// <summary>
/// 一个自定义的 TypeConverter用于在 PropertyGrid 中为模型选择提供一个下拉列表。
/// </summary>
public class ModelSelectionConverter : Int32Converter // 我们转换的是整数 (ModelId)
{
// 告诉 PropertyGrid我们支持从一个标准值集合中进行选择
public override bool GetStandardValuesSupported(ITypeDescriptorContext context)
{
return true;
}
// 告诉 PropertyGrid我们只允许用户从列表中选择不允许手动输入
public override bool GetStandardValuesExclusive(ITypeDescriptorContext context)
{
return true;
}
// 这是最核心的方法:提供标准值的列表
public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
{
try
{
// 从中央配置管理器获取所有已配置的模型
var modelSettings = ConfigurationManager.GetCurrentConfig().ModelSettings;
if (modelSettings == null || modelSettings.Count == 0)
{
// 如果没有模型,返回一个包含默认值 "0"(代表“无”)的列表
return new StandardValuesCollection(new[] { 0 });
}
// 使用LINQ从模型列表中提取所有模型的ID
// .Prepend(0) 在列表开头添加一个 "0",代表“未选择”的选项
var modelIds = modelSettings.Select(m => m.Id).Prepend(0).ToArray();
return new StandardValuesCollection(modelIds);
}
catch
{
// 如果在获取过程中发生任何异常,返回一个安全的默认值
return new StandardValuesCollection(new[] { 0 });
}
}
// --- 为了让下拉列表显示模型名称而不是ID我们还需要重写以下方法 ---
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
{
return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
}
// 将模型ID转换为其对应的名称字符串以便在UI上显示
public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
{
if (destinationType == typeof(string) && value is int modelId)
{
if (modelId == 0)
{
return "未选择"; // 对ID为0的特殊处理
}
// 查找ID对应的模型名称
var model = ConfigurationManager.GetCurrentConfig()
.ModelSettings?
.FirstOrDefault(m => m.Id == modelId);
// 返回 "名称 (ID)" 的格式,更清晰
return model != null ? $"{model.Name} (ID: {model.Id})" : "未知模型";
}
return base.ConvertTo(context, culture, value, destinationType);
}
// 将用户在UI上选择的名称字符串转换回其对应的模型ID以便保存
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
if (value is string displayString)
{
if (displayString == "未选择")
{
return 0;
}
// 从 "名称 (ID: X)" 格式中解析出ID
if (displayString.Contains("(ID: ") && displayString.EndsWith(")"))
{
var startIndex = displayString.IndexOf("(ID: ") + 5;
var endIndex = displayString.Length - 1;
var idString = displayString.Substring(startIndex, endIndex - startIndex);
if (int.TryParse(idString, out int modelId))
{
return modelId;
}
}
}
return base.ConvertFrom(context, culture, value);
}
}
}

View File

@@ -0,0 +1,161 @@
using Check.Main.Camera;
using Check.Main.Infer;
using Check.Main.UI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing.Design;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms.Design;
namespace Check.Main.Common
{
public class ProcessConfig
{
private string _logPath = Path.Combine(Application.StartupPath, "Logs");
private List<CameraSettings> _cameraSettings = new List<CameraSettings>();
private List<ModelSettings> _modelaSettings = new List<ModelSettings>();
[Category("常规设置"), DisplayName("日志路径"), Description("设置日志文件的保存目录。")]
[Editor(typeof(FolderNameEditor), typeof(UITypeEditor))] // 使用内置的文件夹选择器
public string LogPath
{
get => _logPath;
set => _logPath = value;
}
[Category("核心配置"), DisplayName("相机配置"), Description("点击 '...' 按钮来配置所有相机。")]
[Editor(typeof(CameraConfigEditor), typeof(UITypeEditor))] // 【关键】指定使用我们自定义的编辑器
[TypeConverter(typeof(CameraConfigConverter))] // 【关键】指定使用我们自定义的类型转换器来显示文本
public List<CameraSettings> CameraSettings
{
get => _cameraSettings;
set => _cameraSettings = value;
}
[Category("核心配置"), DisplayName("模型配置"), Description("点击 '...' 按钮来配置所有模型。")]
[Editor(typeof(ModelConfigEditor), typeof(UITypeEditor))] // 【关键】指定使用我们自定义的编辑器
[TypeConverter(typeof(ModelConfigConverter))] // 【关键】指定使用我们自定义的类型转换器来显示文本
public List<ModelSettings> ModelSettings
{
get => _modelaSettings;
set => _modelaSettings = value;
}
}
/// <summary>
/// 自定义TypeConverter用于在PropertyGrid中自定义显示相机列表的文本。
/// </summary>
public class CameraConfigConverter : TypeConverter
{
public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
{
// 声明可以将值转换为字符串
return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
}
public override object ConvertTo(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value, Type destinationType)
{
// 如果目标类型是字符串,并且值是相机设置列表
if (destinationType == typeof(string) && value is List<CameraSettings> list)
{
// 返回自定义的描述性文本
return $"已配置 {list.Count} 个相机";
}
return base.ConvertTo(context, culture, value, destinationType);
}
}
/// <summary>
/// 自定义TypeConverter用于在PropertyGrid中自定义显示相机列表的文本。
/// </summary>
public class ModelConfigConverter : TypeConverter
{
public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
{
// 声明可以将值转换为字符串
return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
}
public override object ConvertTo(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value, Type destinationType)
{
// 如果目标类型是字符串,并且值是相机设置列表
if (destinationType == typeof(string) && value is List<ModelSettings> list)
{
// 返回自定义的描述性文本
return $"已配置 {list.Count} 个模型";
}
return base.ConvertTo(context, culture, value, destinationType);
}
}
/// <summary>
/// 自定义UITypeEditor用于在点击 "..." 时打开相机配置窗体。
/// </summary>
public class CameraConfigEditor : UITypeEditor
{
// 1. 设置编辑样式为模态对话框
public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context)
{
return UITypeEditorEditStyle.Modal;
}
// 2. 重写编辑方法
public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value)
{
// 'value' 就是当前属性的值,即 List<CameraSettings>
var settingsList = value as List<CameraSettings>;
// 使用 FrmCamConfig 作为对话框进行编辑
// 我们需要给 FrmCamConfig 添加一个新的构造函数
using (var camConfigForm = new FrmCamConfig(settingsList))
{
// 以对话框形式显示窗体
if (camConfigForm.ShowDialog() == DialogResult.OK)
{
// 如果用户点击了“确定”,返回修改后的新列表
return camConfigForm._settingsList;
}
}
// 如果用户点击了“取消”或直接关闭了窗口,返回原始值,不做任何更改
return value;
}
}
/// <summary>
/// 【新增】自定义UITypeEditor用于在点击 "..." 时打开模型配置窗体。
/// </summary>
public class ModelConfigEditor : UITypeEditor
{
// 1. 设置编辑样式为模态对话框
public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context)
{
return UITypeEditorEditStyle.Modal;
}
// 2. 重写编辑方法
public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value)
{
// 'value' 就是当前属性的值,即 List<CameraSettings>
var settingsList = value as List<ModelSettings>;
// 使用ModelEditor 作为对话框进行编辑
// 我们需要给 FrmCamConfig 添加一个新的构造函数
using (var modelConfigForm = new ModelListEditor(settingsList))
{
// 以对话框形式显示窗体
if (modelConfigForm.ShowDialog() == DialogResult.OK)
{
// 如果用户点击了“确定”,返回修改后的新列表
return modelConfigForm._settingsList;
}
}
// 如果用户点击了“取消”或直接关闭了窗口,返回原始值,不做任何更改
return value;
}
}
}

View File

@@ -0,0 +1,29 @@
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Check.Main.Common
{
public class ProcessingCompletedEventArgs : EventArgs, IDisposable
{
public int CameraIndex { get; }
public long ProductId { get; }
public SKImage ResultImage { get; } // 【修改】携带已绘制好结果的SKImage
public ProcessingCompletedEventArgs(int cameraIndex, long productId, SKImage resultImage)
{
CameraIndex = cameraIndex;
ProductId = productId;
ResultImage = resultImage;
}
// 实现 IDisposable 以确保SKImage资源被释放
public void Dispose()
{
ResultImage?.Dispose();
}
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace Check.Main.Common
{
/// <summary>
/// 一个自定义的类型转换器用于让PropertyGrid等控件显示枚举成员的Description特性而不是成员名。
/// </summary>
public class EnumDescriptionTypeConverter : EnumConverter
{
/// <summary>
/// 构造函数,传入枚举类型。
/// </summary>
/// <param name="type">要进行转换的枚举类型。</param>
public EnumDescriptionTypeConverter(Type type) : base(type)
{
}
/// <summary>
/// 重写此方法,将枚举值转换为其描述文本。
/// 这是从 "数据" -> "UI显示" 的过程。
/// </summary>
public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
{
// 如果目标类型是字符串,并且我们有一个有效的枚举值
if (destinationType == typeof(string) && value != null)
{
// 获取枚举值的字段信息
FieldInfo fi = value.GetType().GetField(value.ToString());
if (fi != null)
{
// 查找该字段上的DescriptionAttribute
var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false);
// 如果找到了Description特性就返回它的描述文本否则返回基类的默认行为即成员名
return ((attributes.Length > 0) && (!String.IsNullOrEmpty(attributes[0].Description)))
? attributes[0].Description
: value.ToString();
}
}
// 对于其他所有情况,使用基类的默认转换逻辑
return base.ConvertTo(context, culture, value, destinationType);
}
/// <summary>
/// 重写此方法,将描述文本转换回其对应的枚举值。
/// 这是从 "UI显示" -> "数据" 的过程。
/// </summary>
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
// 如果传入的值是字符串
if (value is string)
{
// 遍历枚举类型的所有成员
foreach (FieldInfo fi in EnumType.GetFields())
{
// 查找该成员上的DescriptionAttribute
var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false);
// 如果找到了Description并且它的描述文本与传入的字符串匹配
if ((attributes.Length > 0) && (attributes[0].Description == (string)value))
{
// 返回这个字段(成员)对应的枚举值
return Enum.Parse(EnumType, fi.Name);
}
}
}
// 如果没有找到匹配的描述,或者传入的不是字符串,使用基类的默认转换逻辑
return base.ConvertFrom(context, culture, value);
}
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Check.Main.Common
{
/// <summary>
/// 封装了所有生产统计数据的模型。
/// 实现了INotifyPropertyChanged未来可用于数据绑定。
/// </summary>
public class StatisticsData : INotifyPropertyChanged
{
private int _goodCount;
private int _ngCount;
public int GoodCount
{
get => _goodCount;
private set { _goodCount = value; OnPropertyChanged(nameof(GoodCount)); OnPropertyChanged(nameof(TotalCount)); OnPropertyChanged(nameof(YieldRate)); }
}
public int NgCount
{
get => _ngCount;
private set { _ngCount = value; OnPropertyChanged(nameof(NgCount)); OnPropertyChanged(nameof(TotalCount)); OnPropertyChanged(nameof(YieldRate)); }
}
public int TotalCount => GoodCount + NgCount;
public double YieldRate => TotalCount == 0 ? 0 : (double)GoodCount / TotalCount;
/// <summary>
/// 根据检测结果更新统计数据。
/// </summary>
public void UpdateWithResult(bool isOk)
{
if (isOk) GoodCount++;
else NgCount++;
}
/// <summary>
/// 重置所有统计数据。
/// </summary>
public void Reset()
{
GoodCount = 0;
NgCount = 0;
}
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
/// <summary>
/// 用于在检测完成事件中传递结果的事件参数。
/// </summary>
public class DetectionResultEventArgs : EventArgs
{
public bool IsOK { get; }
public DetectionResultEventArgs(bool isOk)
{
IsOK = isOk;
}
}
}

View File

@@ -0,0 +1,82 @@
using Check.Main.Camera;
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Check.Main.Common
{
public static class StatisticsExporter
{
/// <summary>
/// 将给定的统计数据导出到指定的Excel文件路径。
/// </summary>
/// <param name="data">要导出的统计数据对象。</param>
/// <param name="customFileName">自定义的文件名部分(如 "Reset" 或 "Shutdown")。</param>
public static void ExportToExcel(StatisticsData data, string customFileName)
{
if (data.TotalCount == 0) return; // 如果没有数据,则不导出
try
{
string directory = Path.Combine(Application.StartupPath, "Statistics");
Directory.CreateDirectory(directory); // 确保文件夹存在
string fileName = $"Statistics_{customFileName}_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.xlsx";
string filePath = Path.Combine(directory, fileName);
// 创建工作簿和工作表
IWorkbook workbook = new XSSFWorkbook();
ISheet sheet = workbook.CreateSheet("生产统计");
// --- 创建样式 (可选,但能让表格更好看) ---
IFont boldFont = workbook.CreateFont();
boldFont.IsBold = true;
ICellStyle headerStyle = workbook.CreateCellStyle();
headerStyle.SetFont(boldFont);
// --- 创建表头 ---
IRow headerRow = sheet.CreateRow(0);
headerRow.CreateCell(0).SetCellValue("项目");
headerRow.CreateCell(1).SetCellValue("数值");
headerRow.GetCell(0).CellStyle = headerStyle;
headerRow.GetCell(1).CellStyle = headerStyle;
// --- 填充数据 ---
sheet.CreateRow(1).CreateCell(0).SetCellValue("良品数 (OK)");
sheet.GetRow(1).CreateCell(1).SetCellValue(data.GoodCount);
sheet.CreateRow(2).CreateCell(0).SetCellValue("不良品数 (NG)");
sheet.GetRow(2).CreateCell(1).SetCellValue(data.NgCount);
sheet.CreateRow(3).CreateCell(0).SetCellValue("产品总数");
sheet.GetRow(3).CreateCell(1).SetCellValue(data.TotalCount);
sheet.CreateRow(4).CreateCell(0).SetCellValue("良率 (Yield)");
sheet.GetRow(4).CreateCell(1).SetCellValue(data.YieldRate.ToString("P2"));
// 自动调整列宽
sheet.AutoSizeColumn(0);
sheet.AutoSizeColumn(1);
// --- 写入文件 ---
using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
workbook.Write(fs);
}
ThreadSafeLogger.Log($"统计数据已成功导出到: {filePath}");
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[ERROR] 导出统计数据失败: {ex.Message}");
MessageBox.Show($"导出统计数据失败: {ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
}

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Check.Main.Common
{
/// <summary>
/// 一个线程安全的、基于队列的日志记录器。
/// 使用一个独立的后台线程来处理文件写入,避免业务线程阻塞。
/// </summary>
public static class ThreadSafeLogger
{
// 使用线程安全的队列作为日志消息的缓冲区
private static readonly BlockingCollection<string> _logQueue = new BlockingCollection<string>();
// 日志写入线程
private static Thread _logWriterThread;
private static StreamWriter _logFileWriter;
// 事件用于将格式化后的日志消息广播给UI等监听者
public static event Action<string> OnLogMessage;
/// <summary>
/// 初始化日志记录器,启动后台写入线程。
/// </summary>
public static void Initialize()
{
try
{
string logDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
Directory.CreateDirectory(logDirectory);
string logFileName = $"Log_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt";
string logFilePath = Path.Combine(logDirectory, logFileName);
_logFileWriter = new StreamWriter(logFilePath, append: true, encoding: Encoding.UTF8) { AutoFlush = true };
// 创建并启动后台线程
_logWriterThread = new Thread(ProcessLogQueue)
{
IsBackground = true, // 设置为后台线程,这样主程序退出时它会自动终止
Name = "LogWriterThread"
};
_logWriterThread.Start();
Log("日志系统已初始化。");
}
catch (Exception ex)
{
// 如果初始化失败尝试通过事件通知UI
OnLogMessage?.Invoke($"[CRITICAL] 日志系统初始化失败: {ex.Message}");
}
}
/// <summary>
/// 将一条日志消息添加到队列中。这个方法是线程安全的,且执行速度非常快。
/// </summary>
/// <param name="message">原始日志消息。</param>
public static void Log(string message)
{
string formattedMessage = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
_logQueue.Add(formattedMessage);
}
/// <summary>
/// 后台线程的工作方法。它会持续不断地从队列中取出消息并处理。
/// </summary>
private static void ProcessLogQueue()
{
// GetConsumingEnumerable会阻塞等待直到有新的项加入队列或队列被标记为已完成
foreach (string message in _logQueue.GetConsumingEnumerable())
{
try
{
// 1. 写入文件
_logFileWriter?.WriteLine(message);
// 2. 触发事件通知UI
OnLogMessage?.Invoke(message);
}
catch
{
// 忽略在日志线程本身发生的写入错误
}
}
}
/// <summary>
/// 关闭日志记录器,释放资源。
/// </summary>
public static void Shutdown()
{
Log("日志系统正在关闭...");
// 标记队列不再接受新的项目。这会让ProcessLogQueue中的循环在处理完所有剩余项后自然结束。
_logQueue.CompleteAdding();
// 等待日志线程处理完所有剩余的日志最多等待2秒
_logWriterThread?.Join(2000);
// 关闭文件流
_logFileWriter?.Close();
_logFileWriter?.Dispose();
}
}
}

View File

@@ -0,0 +1,243 @@
using Check.Main.Common;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Serialization;
namespace Check.Main.Dispatch
{
public static class ProductManager
{
private static readonly string _productRootPath = Path.Combine(Application.StartupPath, "Product");
private static readonly string _productListFilePath = Path.Combine(Application.StartupPath, "products.json");
// --- 公共属性和事件 ---
public static List<string> ProductList { get; private set; } = new List<string>();
public static string CurrentProductName { get; private set; }
public static ProcessConfig CurrentConfig { get; private set; }
/// <summary>
/// 当产品切换或配置加载后触发。
/// </summary>
public static event Action OnProductChanged;
/// <summary>
/// 静态构造函数,在程序启动时自动执行。
/// </summary>
static ProductManager()
{
Directory.CreateDirectory(_productRootPath); // 确保Product文件夹存在
LoadProductList();
// 默认加载列表中的第一个产品,或者创建一个默认的
SwitchToProduct(ProductList.FirstOrDefault() ?? "DefaultProduct");
}
/// <summary>
/// 切换到指定的产品,并加载其配置。
/// </summary>
public static bool SwitchToProduct(string productName)
{
if (string.IsNullOrWhiteSpace(productName)) return false;
ThreadSafeLogger.Log($"正在切换到产品: {productName}");
var config = LoadConfigForProduct(productName);
if (config == null)
{
// 如果加载失败(例如新创建的产品还没有配置文件),则创建一个新的
config = new ProcessConfig();
}
CurrentProductName = productName;
CurrentConfig = config;
// 广播产品已变更的通知
OnProductChanged?.Invoke();
return true;
}
/// <summary>
/// 添加一个新产品。
/// </summary>
public static bool AddNewProduct(string newProductName)
{
if (string.IsNullOrWhiteSpace(newProductName) || ProductList.Contains(newProductName))
{
return false; // 名称无效或已存在
}
// 创建一个全新的、空的配置
var newConfig = new ProcessConfig();
// 保存这个新产品的空配置,这也会创建文件夹
if (SaveConfigForProduct(newProductName, newConfig))
{
ProductList.Add(newProductName);
SaveProductList(); // 更新产品列表文件
SwitchToProduct(newProductName); // 创建后立即切换到新产品
return true;
}
return false;
}
/// <summary>
/// 删除指定的产品及其所有相关配置。
/// </summary>
/// <param name="productNameToDelete">要删除的产品名称。</param>
/// <returns>成功删除返回 true否则返回 false。</returns>
public static bool DeleteProduct(string productNameToDelete)
{
if (string.IsNullOrWhiteSpace(productNameToDelete) || !ProductList.Contains(productNameToDelete))
{
ThreadSafeLogger.Log($"[警告] 尝试删除一个不存在的产品: {productNameToDelete}");
return false; // 产品不存在
}
ThreadSafeLogger.Log($"正在删除产品: {productNameToDelete}...");
try
{
// 1. 从产品列表中移除
ProductList.Remove(productNameToDelete);
SaveProductList(); // 更新列表文件
// 2. 删除对应的配置文件夹
var safeFolderName = GetSafeFolderName(productNameToDelete);
var productDir = Path.Combine(_productRootPath, safeFolderName);
if (Directory.Exists(productDir))
{
Directory.Delete(productDir, true); // true 表示递归删除所有子文件和子文件夹
}
ThreadSafeLogger.Log($"产品 '{productNameToDelete}' 已成功删除。");
// 3. 如果被删除的是当前活动产品,则需要切换到一个新的有效产品
if (CurrentProductName == productNameToDelete)
{
// 切换到列表中的第一个产品,或者如果列表为空,则创建一个默认产品
string nextProduct = ProductList.FirstOrDefault() ?? "DefaultProduct";
SwitchToProduct(nextProduct);
}
else
{
// 如果删除的不是当前产品,我们仍然需要触发一次事件,
// 以便UI主要是下拉列表能够刷新它的数据源。
OnProductChanged?.Invoke();
}
return true;
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[错误] 删除产品 '{productNameToDelete}' 失败: {ex.Message}");
// 如果出错,最好把刚删除的项加回来,保持状态一致性
if (!ProductList.Contains(productNameToDelete))
{
ProductList.Add(productNameToDelete);
}
return false;
}
}
/// <summary>
/// 为当前产品保存更改。
/// </summary>
public static void SaveCurrentProductConfig()
{
if (CurrentConfig != null && !string.IsNullOrWhiteSpace(CurrentProductName))
{
SaveConfigForProduct(CurrentProductName, CurrentConfig);
}
}
#region
private static void LoadProductList()
{
try
{
if (File.Exists(_productListFilePath))
{
var json = File.ReadAllText(_productListFilePath);
ProductList = JsonConvert.DeserializeObject<List<string>>(json) ?? new List<string>();
}
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[错误] 加载产品列表失败: {ex.Message}");
ProductList = new List<string>();
}
}
private static void SaveProductList()
{
try
{
var json = JsonConvert.SerializeObject(ProductList, Newtonsoft.Json.Formatting.Indented);
File.WriteAllText(_productListFilePath, json);
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[错误] 保存产品列表失败: {ex.Message}");
}
}
private static ProcessConfig LoadConfigForProduct(string productName)
{
var safeFolderName = GetSafeFolderName(productName);
var configPath = Path.Combine(_productRootPath, safeFolderName, "config.xml");
if (!File.Exists(configPath)) return null;
try
{
XmlSerializer serializer = new XmlSerializer(typeof(ProcessConfig));
using (var fs = new FileStream(configPath, FileMode.Open, FileAccess.Read))
{
return (ProcessConfig)serializer.Deserialize(fs);
}
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[错误] 加载产品 '{productName}' 的配置失败: {ex.Message}");
return null;
}
}
private static bool SaveConfigForProduct(string productName, ProcessConfig config)
{
try
{
var safeFolderName = GetSafeFolderName(productName);
var productDir = Path.Combine(_productRootPath, safeFolderName);
Directory.CreateDirectory(productDir); // 确保文件夹存在
var configPath = Path.Combine(productDir, "config.xml");
XmlSerializer serializer = new XmlSerializer(typeof(ProcessConfig));
using (var fs = new FileStream(configPath, FileMode.Create, FileAccess.Write))
{
serializer.Serialize(fs, config);
}
return true;
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[错误] 保存产品 '{productName}' 的配置失败: {ex.Message}");
return false;
}
}
private static string GetSafeFolderName(string productName)
{
// 移除所有不适合做文件夹名称的字符
string invalidChars = new string(Path.GetInvalidFileNameChars()) + new string(Path.GetInvalidPathChars());
Regex r = new Regex(string.Format("[{0}]", Regex.Escape(invalidChars)));
return r.Replace(productName, "");
}
#endregion
}
}

180
Check.Main/FrmMain.Designer.cs generated Normal file
View File

@@ -0,0 +1,180 @@
namespace Check.Main
{
partial class FrmMain
{
/// <summary>
/// 必需的设计器变量。
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 清理所有正在使用的资源。
/// </summary>
/// <param name="disposing">如果应释放托管资源,为 true否则为 false。</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows
/// <summary>
/// 设计器支持所需的方法 - 不要修改
/// 使用代码编辑器修改此方法的内容。
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(FrmMain));
menuStrip1 = new MenuStrip();
ToolStripMenuItem = new ToolStripMenuItem();
退ToolStripMenuItem = new ToolStripMenuItem();
ToolStripMenuSaveLayou = new ToolStripMenuItem();
ToolStripMenuItem = new ToolStripMenuItem();
ToolStripMenuItem = new ToolStripMenuItem();
ToolStripMenuItem = new ToolStripMenuItem();
ToolStripMenuItem = new ToolStripMenuItem();
statusStrip1 = new StatusStrip();
uiTableLayoutPanel1 = new Sunny.UI.UITableLayoutPanel();
dockPanel1 = new WeifenLuo.WinFormsUI.Docking.DockPanel();
menuStrip1.SuspendLayout();
uiTableLayoutPanel1.SuspendLayout();
SuspendLayout();
//
// menuStrip1
//
menuStrip1.Items.AddRange(new ToolStripItem[] { ToolStripMenuItem, ToolStripMenuItem, ToolStripMenuItem });
menuStrip1.Location = new Point(0, 0);
menuStrip1.Name = "menuStrip1";
menuStrip1.Padding = new Padding(7, 3, 0, 3);
menuStrip1.Size = new Size(933, 27);
menuStrip1.TabIndex = 0;
menuStrip1.Text = "MainMenu";
//
// 文件ToolStripMenuItem
//
ToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { 退ToolStripMenuItem, ToolStripMenuSaveLayou });
ToolStripMenuItem.Name = "文件ToolStripMenuItem";
ToolStripMenuItem.Size = new Size(44, 21);
ToolStripMenuItem.Text = "文件";
//
// 退出ToolStripMenuItem
//
退ToolStripMenuItem.Name = "退出ToolStripMenuItem";
退ToolStripMenuItem.Size = new Size(124, 22);
退ToolStripMenuItem.Text = "退出";
//
// ToolStripMenuSaveLayou
//
ToolStripMenuSaveLayou.Name = "ToolStripMenuSaveLayou";
ToolStripMenuSaveLayou.Size = new Size(124, 22);
ToolStripMenuSaveLayou.Text = "保存布局";
ToolStripMenuSaveLayou.Click += ToolStripMenuSaveLayou_Click;
//
// 视图ToolStripMenuItem
//
ToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { ToolStripMenuItem, ToolStripMenuItem });
ToolStripMenuItem.Name = "视图ToolStripMenuItem";
ToolStripMenuItem.Size = new Size(44, 21);
ToolStripMenuItem.Text = "视图";
//
// 配置ToolStripMenuItem
//
ToolStripMenuItem.Name = "配置ToolStripMenuItem";
ToolStripMenuItem.Size = new Size(180, 22);
ToolStripMenuItem.Text = "配置";
ToolStripMenuItem.Click += ToolStripMenuItem_Click;
//
// 日志ToolStripMenuItem
//
ToolStripMenuItem.Name = "日志ToolStripMenuItem";
ToolStripMenuItem.Size = new Size(180, 22);
ToolStripMenuItem.Text = "日志";
ToolStripMenuItem.Click += ToolStripMenuItem_Click;
//
// 控制面板ToolStripMenuItem
//
ToolStripMenuItem.Name = "控制面板ToolStripMenuItem";
ToolStripMenuItem.Size = new Size(68, 21);
ToolStripMenuItem.Text = "控制面板";
ToolStripMenuItem.Click += ToolStripMenuItem_Click;
//
// statusStrip1
//
statusStrip1.Location = new Point(0, 616);
statusStrip1.Name = "statusStrip1";
statusStrip1.Padding = new Padding(1, 0, 16, 0);
statusStrip1.Size = new Size(933, 22);
statusStrip1.TabIndex = 2;
statusStrip1.Text = "statusStrip1";
//
// uiTableLayoutPanel1
//
uiTableLayoutPanel1.ColumnCount = 2;
uiTableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 76F));
uiTableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 24F));
uiTableLayoutPanel1.Controls.Add(dockPanel1, 0, 0);
uiTableLayoutPanel1.Dock = DockStyle.Fill;
uiTableLayoutPanel1.Location = new Point(0, 27);
uiTableLayoutPanel1.Margin = new Padding(4);
uiTableLayoutPanel1.Name = "uiTableLayoutPanel1";
uiTableLayoutPanel1.RowCount = 1;
uiTableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 50F));
uiTableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 50F));
uiTableLayoutPanel1.Size = new Size(933, 589);
uiTableLayoutPanel1.TabIndex = 3;
uiTableLayoutPanel1.TagString = null;
//
// dockPanel1
//
uiTableLayoutPanel1.SetColumnSpan(dockPanel1, 2);
dockPanel1.Dock = DockStyle.Fill;
dockPanel1.Location = new Point(4, 4);
dockPanel1.Margin = new Padding(4);
dockPanel1.Name = "dockPanel1";
dockPanel1.Size = new Size(925, 581);
dockPanel1.TabIndex = 0;
//
// FrmMain
//
AutoScaleDimensions = new SizeF(7F, 17F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(933, 638);
Controls.Add(uiTableLayoutPanel1);
Controls.Add(statusStrip1);
Controls.Add(menuStrip1);
Icon = (Icon)resources.GetObject("$this.Icon");
MainMenuStrip = menuStrip1;
Margin = new Padding(4);
Name = "FrmMain";
Text = "星科瑞升视觉检测设备";
WindowState = FormWindowState.Maximized;
FormClosing += FrmMain_FormClosing;
Load += FrmMain_Load;
menuStrip1.ResumeLayout(false);
menuStrip1.PerformLayout();
uiTableLayoutPanel1.ResumeLayout(false);
ResumeLayout(false);
PerformLayout();
}
#endregion
private System.Windows.Forms.MenuStrip menuStrip1;
private System.Windows.Forms.StatusStrip statusStrip1;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem 退ToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem;
private Sunny.UI.UITableLayoutPanel uiTableLayoutPanel1;
private WeifenLuo.WinFormsUI.Docking.DockPanel dockPanel1;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuSaveLayou;
}
}

297
Check.Main/FrmMain.cs Normal file
View File

@@ -0,0 +1,297 @@
using Check.Main.Camera;
using Check.Main.Common;
using Check.Main.UI;
using HslCommunication;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using WeifenLuo.WinFormsUI.Docking;
namespace Check.Main
{
public partial class FrmMain : Form
{
// private FrmCamConfig _formCameraConfig;
private FrmConfig _frmConfig;
private FrmLog _formLog;
private ThemeBase _theme = new VS2015BlueTheme(); // 外观主题
public DockPanel MainDockPanel => this.dockPanel1;
private FormControlPanel _formControlPanel;
private FormStatistics _formStatistics;
private readonly string _layoutConfigFile = Path.Combine(Application.StartupPath, "layout.xml");
//用于反序列化时创建窗体的委托
private DeserializeDockContent _deserializeDockContent;
public FrmMain()
{
InitializeComponent();
dockPanel1.Theme = _theme;
IsMdiContainer = true;
_deserializeDockContent = new DeserializeDockContent(GetContentFromPersistString);
}
private void FrmMain_Load(object sender, EventArgs e)
{
EasyPlcClient easyPlcClient = new EasyPlcClient("127.0.0.1", 502, 1);
easyPlcClient.ConnectAsync();
_frmConfig = new FrmConfig { Text = "主程序配置" };
_formLog = new FrmLog { Text = "运行日志" };
_formStatistics = new FormStatistics { Text = "生产统计" };
_formControlPanel = new FormControlPanel { Text = "控制面板" };
// 为每个子窗体订阅 FormClosing 事件
_frmConfig.FormClosing += DockContent_FormClosing;
_formLog.FormClosing += DockContent_FormClosing;
_formStatistics.FormClosing += DockContent_FormClosing;
_formControlPanel.FormClosing += DockContent_FormClosing;
ThreadSafeLogger.Initialize();
ThreadSafeLogger.OnLogMessage += (msg) => { _formLog.AddLog(msg); };
// 2. 尝试加载布局文件
if (File.Exists(_layoutConfigFile))
{
try
{
// 使用委托加载布局
dockPanel1.LoadFromXml(_layoutConfigFile, _deserializeDockContent);
ThreadSafeLogger.Log("成功加载用户布局。");
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"加载布局失败: {ex.Message}。将使用默认布局。");
// 如果加载失败,则使用默认布局
ShowDefaultLayout();
}
}
else
{
// 3. 如果布局文件不存在,则显示默认布局
ThreadSafeLogger.Log("未找到布局文件,使用默认布局。");
ShowDefaultLayout();
}
}
/// <summary>
/// 将相机名称添加到状态栏
/// </summary>
public void AddCameraToStatusStrip(string name)
{
if (statusStrip1.InvokeRequired)
{
statusStrip1.Invoke(new Action(() => AddCameraToStatusStrip(name)));
return;
}
var label = new ToolStripStatusLabel(name)
{
Name = "status_" + name,
BorderSides = ToolStripStatusLabelBorderSides.All,
Spring = false,
};
label.Click += StatusLabel_Click;
statusStrip1.Items.Add(label);
}
private void ToolStripMenuItem_Click(object sender, EventArgs e)
{
if (_formControlPanel == null || _formControlPanel.IsDisposed)
{
_formControlPanel = new FormControlPanel { Text = "控制面板" };
_formControlPanel.FormClosing += DockContent_FormClosing;
}
_formControlPanel.Show(this.dockPanel1);
}
/// <summary>
/// 清空状态栏中的相机名称
/// </summary>
public void ClearStatusStrip()
{
if (statusStrip1.InvokeRequired)
{
statusStrip1.Invoke(new Action(ClearStatusStrip));
return;
}
// 从后往前删,避免索引问题
for (int i = statusStrip1.Items.Count - 1; i >= 0; i--)
{
if (statusStrip1.Items[i] is ToolStripStatusLabel && statusStrip1.Items[i].Name.StartsWith("status_"))
{
statusStrip1.Items.RemoveAt(i);
}
}
}
private void StatusLabel_Click(object sender, EventArgs e)
{
if (sender is ToolStripStatusLabel label)
{
string cameraName = label.Text;
ThreadSafeLogger.Log($"用户点击状态栏标签: {cameraName}");
bool foundOriginal = false;
bool foundResult = false;
// 1. 尝试重新显示“原图”窗口
if (CameraManager.OriginalImageDisplays.TryGetValue(cameraName, out var originalDisplayForm))
{
// 使用 Show() 方法来重新激活或显示隐藏的窗口
// 传入 DockPanel 确保它知道在哪里显示
originalDisplayForm.Show(this.dockPanel1);
originalDisplayForm.Activate(); // 调用 Activate() 确保它成为当前焦点窗口
foundOriginal = true;
}
// 2. 尝试重新显示“结果”窗口
if (CameraManager.ResultImageDisplays.TryGetValue(cameraName, out var resultDisplayForm))
{
resultDisplayForm.Show(this.dockPanel1);
resultDisplayForm.Activate();
foundResult = true;
}
// 3. 提供反馈
if (foundOriginal || foundResult)
{
ThreadSafeLogger.Log($"已重新激活相机 '{cameraName}' 的显示窗口。");
}
else
{
ThreadSafeLogger.Log($"[警告] 未能找到相机 '{cameraName}' 对应的活动显示窗口。可能设备已关闭。");
}
}
}
/// <summary>
/// 根据持久化字符串创建或返回对应的窗体实例。
/// 这是DockPanel Suite反序列化布局时需要的回调方法。
/// </summary>
/// <param name="persistString">在XML中代表一个窗体的唯一字符串通常是其类型名。</param>
/// <returns>对应的窗体实例。</returns>
private IDockContent GetContentFromPersistString(string persistString)
{
if (persistString == typeof(FormControlPanel).ToString())
return _formControlPanel;
if (persistString == typeof(FrmConfig).ToString())
return _frmConfig;
if (persistString == typeof(FrmLog).ToString())
return _formLog;
if (persistString == typeof(FormStatistics).ToString())
return _formStatistics;
// 对于图像显示窗口,由于它们是动态创建的,情况会更复杂。
// 在当前设计中,我们不保存图像窗口的布局,它们会在应用相机配置时重新创建。
// 如果需要保存它们需要在CameraManager中管理它们的持久化字符串。
// 目前的设计下返回null是安全的。
return null;
}
/// <summary>
/// 显示程序的默认窗口布局。
/// </summary>
private void ShowDefaultLayout()
{
// 确保所有窗体都未被意外关闭
if (_formControlPanel.IsDisposed) _formControlPanel = new FormControlPanel();
if (_frmConfig.IsDisposed) _frmConfig = new FrmConfig();
if (_formLog.IsDisposed) _formLog = new FrmLog();
if (_formStatistics.IsDisposed) _formStatistics = new FormStatistics();
// 显示默认窗口
_frmConfig.Show(dockPanel1, DockState.DockLeft);
_formLog.Show(dockPanel1, DockState.DockBottom);
_formControlPanel.Show(dockPanel1, DockState.DockRight);
_formStatistics.Show(dockPanel1, DockState.DockTop);
}
/// <summary>
/// 【新增】保存当前窗口布局到XML文件。
/// </summary>
private void SaveLayout()
{
try
{
// 将当前DockPanel的布局保存到指定文件
dockPanel1.SaveAsXml(_layoutConfigFile);
ThreadSafeLogger.Log("布局已成功保存!");
//MessageBox.Show("布局已成功保存!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
ThreadSafeLogger.Log("保存布局失败: " + ex.Message);
//MessageBox.Show("保存布局失败: " + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void FrmMain_FormClosing(object sender, FormClosingEventArgs e)
{
if (_formStatistics != null && !_formStatistics.IsDisposed)
{
StatisticsExporter.ExportToExcel(_formStatistics.CurrentStatistics, "Shutdown");
}
CameraManager.Shutdown();
ThreadSafeLogger.Shutdown();
}
private void ToolStripMenuSaveLayou_Click(object sender, EventArgs e)
{
SaveLayout();
}
private void ToolStripMenuItem_Click(object sender, EventArgs e)
{
//_frmConfig.Show();
// 如果窗体因意外被销毁,则重新创建它
if (_frmConfig == null || _frmConfig.IsDisposed)
{
_frmConfig = new FrmConfig() { Text = "主程序配置" };
_frmConfig.FormClosing += DockContent_FormClosing;
}
// 调用 Show() 会自动处理隐藏和显示逻辑
_frmConfig.Show(this.dockPanel1);
}
private void ToolStripMenuItem_Click(object sender, EventArgs e)
{
if (_formLog == null || _formLog.IsDisposed)
{
_formLog = new FrmLog { Text = "运行日志" };
_formLog.FormClosing += DockContent_FormClosing;
}
_formLog.Show(this.dockPanel1);
}
/// <summary>
/// 处理所有可停靠子窗体关闭事件的通用方法。
/// 通过取消关闭并隐藏窗口,来实现“假关闭”,以便后续能重新显示。
/// </summary>
private void DockContent_FormClosing(object sender, FormClosingEventArgs e)
{
// 检查关闭原因是否为用户点击了关闭按钮
if (e.CloseReason == CloseReason.UserClosing)
{
// 1. 取消真正的关闭Dispose操作防止窗体被销毁
e.Cancel = true;
// 2. 将窗口隐藏起来
// 我们需要将 sender 转换为 DockContent 类型来访问 Hide() 方法
if (sender is DockContent dockContent)
{
if (dockContent is FormImageDisplay imageDisplay)
{
ThreadSafeLogger.Log($"用户关闭了窗口 '{imageDisplay.Text}',已将其隐藏。");
}
dockContent.Hide();
}
}
}
}
}

3244
Check.Main/FrmMain.resx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
using Check.Main.Camera;
using Check.Main.Common;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Check.Main.Infer
{
public static class DetectionCoordinator
{
/// <summary>
/// 定义存储所有相机处理器的字典
/// 键是相机的唯一编号 (CameraIndex),值是对应的处理器实例。
/// </summary>
private static ConcurrentDictionary<int, CameraProcessor> _processors = new ConcurrentDictionary<int, CameraProcessor>();
/// <summary>
/// 用于在产品组装时进行同步,确保线程安全
/// </summary>
private static ConcurrentDictionary<long, ProductAssembly> _productAssemblies = new ConcurrentDictionary<long, ProductAssembly>();
/// <summary>
/// 可用的相机数量
/// </summary>
private static int _enabledCameraCount = 0;
public static event EventHandler<DetectionResultEventArgs> OnDetectionCompleted;
public static bool IsDetectionRunning { get; private set; } = false;
// OnDetectionCompleted 事件现在也属于这里
//public static event EventHandler<DetectionResultEventArgs> OnDetectionCompleted;
public static void StartDetection()
{
if (!IsDetectionRunning)
{
IsDetectionRunning = true;
ThreadSafeLogger.Log("检测统计已启动。");
}
}
public static void StopDetection()
{
if (IsDetectionRunning)
{
IsDetectionRunning = false;
ThreadSafeLogger.Log("检测统计已停止。");
}
}
public static void Initialize(List<CameraSettings> cameraSettings, List<ModelSettings> modelSettings)
{
Shutdown(); // 先关闭旧的
var enabledCameras = cameraSettings.Where(c => c.IsEnabled).ToList();
_enabledCameraCount = enabledCameras.Count;
if (_enabledCameraCount == 0) return;
foreach (var camSetting in enabledCameras)
{
// 找到与相机编号匹配的模型
var model = modelSettings.FirstOrDefault(m => m.Id == camSetting.ModelID);
if (model == null)
{
ThreadSafeLogger.Log($"[警告] 找不到与相机 #{camSetting.CameraIndex} 匹配的模型,该相机将无法处理图像。");
continue;
}
var processor = new CameraProcessor(camSetting.CameraIndex,camSetting.ModelID);
_processors.TryAdd(camSetting.CameraIndex, processor);
processor.Start();
}
ThreadSafeLogger.Log($"检测协调器已初始化,启动了 {_processors.Count} 个相机处理线程。");
}
public static void EnqueueImage(int cameraIndex, Bitmap bmp)
{
if (_processors.TryGetValue(cameraIndex, out var processor))
{
processor.EnqueueImage(bmp);
}
else
{
// 如果找不到处理器必须释放Bitmap防止泄漏
bmp?.Dispose();
}
}
// 供 CameraProcessor 回调,用以组装产品
public static void AssembleProduct(ImageData data, string result)
{
var assembly = _productAssemblies.GetOrAdd(data.ProductId, (id) => new ProductAssembly(id, _enabledCameraCount));
if (assembly.AddResult(data.CameraIndex, result))
{
string finalResult = assembly.GetFinalResult();
ThreadSafeLogger.Log($"产品 #{assembly.ProductId} 已检测完毕,最终结果: {finalResult}");
// 只有在检测运行时,才触发事件
if (IsDetectionRunning)
{
OnDetectionCompleted?.Invoke(null, new DetectionResultEventArgs(finalResult == "OK"));
}
if (_productAssemblies.TryRemove(assembly.ProductId, out var finishedAssembly))
{
finishedAssembly.Dispose();
}
}
}
/// <summary>
/// 命令所有活动的相机处理器重置它们的内部计数器。
/// </summary>
public static void ResetAllCounters()
{
foreach (var processor in _processors.Values)
{
processor.ResetCounter();
}
ThreadSafeLogger.Log("所有相机处理器的产品计数器已重置。");
}
public static CameraProcessor GetProcessor(int cameraIndex)
{
_processors.TryGetValue(cameraIndex, out var p);
return p;
}
public static IEnumerable<CameraProcessor> GetAllProcessors()
{
return _processors.Values;
}
public static void Shutdown()
{
foreach (var processor in _processors.Values)
{
processor.Dispose();
}
_processors.Clear();
foreach (var assembly in _productAssemblies.Values)
{
assembly.Dispose();
}
_productAssemblies.Clear();
ThreadSafeLogger.Log("检测协调器已关闭。");
}
}
}

View File

@@ -0,0 +1,131 @@
using Check.Main.Common;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text;
using System.Threading.Tasks;
namespace Check.Main.Infer
{
public enum DetectDevice
{
[Description("CPU")]
CPU = 0,
[Description("GPU")]
GPU,
[Description("VPU")]
VPU,
}
public enum ModelVersion
{
[Description("v8")]
V8 = 0,
[Description("v11")]
V11,
}
public enum CheckModelType
{
[Description("分类")]
Classification,
[Description("检测")]
ObjectDetection,
[Description("OBB")]
ObbDetection,
[Description("分割")]
Segmentation,
[Description("位姿")]
PoseEstimation
}
[Serializable] // 确保可被XML序列化
public class ModelSettings : INotifyPropertyChanged, ICloneable
{
public event PropertyChangedEventHandler PropertyChanged;
private int _id;
private string _name = "New Model";
private string _path = "";
private DetectDevice _checkDevice=DetectDevice.CPU;
private ModelVersion _mVersion=ModelVersion.V8;
private CheckModelType _mType = CheckModelType.Classification;
private bool _isEnabled = true;
[Category("基本信息"), DisplayName("模型编号"), Description("模型的唯一标识符,用于与相机编号对应。")]
public int Id
{
get => _id;
set { if (_id != value) { _id = value; OnPropertyChanged(); } }
}
[Category("基本信息"), DisplayName("模型名称"), Description("给模型起一个易于识别的别名。")]
public string Name
{
get => _name;
set { if (_name != value) { _name = value; OnPropertyChanged(); } }
}
[Category("基本信息"), DisplayName("推理设备"), Description("推理模型的设备。")]
[TypeConverter(typeof(EnumDescriptionTypeConverter))]
public DetectDevice CheckDevice
{
get => _checkDevice;
set { if (_checkDevice != value) { _checkDevice = value; OnPropertyChanged(); } }
}
[Category("基本信息"), DisplayName("模型版本"), Description("推理模型的版本。")]
[TypeConverter(typeof(EnumDescriptionTypeConverter))]
public ModelVersion MVersion
{
get => _mVersion;
set { if (_mVersion != value) { _mVersion = value; OnPropertyChanged(); } }
}
[Category("基本信息"), DisplayName("模型类型"), Description("推理模型的类型。")]
[TypeConverter(typeof(EnumDescriptionTypeConverter))]
public CheckModelType MType
{
get => _mType;
set { if (_mType != value) { _mType = value; OnPropertyChanged(); } }
}
[Category("基本信息"), DisplayName("是否启用"), Description("是否在程序启动时是否启用模型")]
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
[Category("文件"), DisplayName("模型路径"), Description("选择模型文件(.onnx, .bin, etc., .pt。")]
[Editor(typeof(System.Windows.Forms.Design.FileNameEditor), typeof(System.Drawing.Design.UITypeEditor))]
public string Path
{
get => _path;
set { if (_path != value) { _path = value; OnPropertyChanged(); } }
}
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public object Clone()
{
return this.MemberwiseClone(); // 浅克隆对于这个类足够了
}
}
}

View File

@@ -0,0 +1,102 @@
using Check.Main.Common;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using YoloDotNet;
using YoloDotNet.Models;
namespace Check.Main.Infer
{
/// <summary>
/// 静态全局YOLO模型管理器。
/// 负责在程序启动时加载所有模型,并在关闭时释放资源。
/// </summary>
public static class YoloModelManager
{
// 使用 ConcurrentDictionary 保证线程安全
private static readonly ConcurrentDictionary<int, Yolo> _loadedModels = new ConcurrentDictionary<int, Yolo>();
/// <summary>
/// 根据模型配置列表初始化所有YOLO模型实例。
/// 应在程序启动时调用一次。
/// </summary>
/// <param name="modelSettings">模型配置列表。</param>
public static void Initialize(List<ModelSettings> modelSettings)
{
Shutdown(); // 先确保清理掉旧实例
if (modelSettings == null) return;
ThreadSafeLogger.Log("开始加载YOLO模型...");
foreach (var setting in modelSettings)
{
bool gpuUse = false;
if (setting.CheckDevice == DetectDevice.GPU)
{
gpuUse = true;
}
if (string.IsNullOrEmpty(setting.Path) || !File.Exists(setting.Path))
{
ThreadSafeLogger.Log($"[警告] 模型 '{setting.Name}' (ID: {setting.Id}) 路径无效或文件不存在,已跳过加载。");
continue;
}
try
{
// 创建YOLO实例
var yolo = new Yolo(new YoloOptions
{
OnnxModel = setting.Path,
// 您可以根据需要从配置中读取这些值
ModelType = (YoloDotNet.Enums.ModelType)setting.MType,
Cuda = gpuUse, // 推荐使用GPU
PrimeGpu = false
});
if (_loadedModels.TryAdd(setting.Id, yolo))
{
ThreadSafeLogger.Log($"成功加载模型 '{setting.Name}' (ID: {setting.Id})。");
}
}
catch (Exception ex)
{
ThreadSafeLogger.Log($"[错误] 加载模型 '{setting.Name}' (ID: {setting.Id}) 失败: {ex.Message}");
}
}
ThreadSafeLogger.Log($"YOLO模型加载完成共成功加载 {_loadedModels.Count} 个模型。");
}
/// <summary>
/// 获取指定ID的已加载YOLO模型。
/// </summary>
/// <param name="modelId">模型编号。</param>
/// <returns>Yolo实例如果未找到则返回null。</returns>
public static Yolo GetModel(int modelId)
{
_loadedModels.TryGetValue(modelId, out var yolo);
return yolo;
}
/// <summary>
/// 释放所有已加载的YOLO模型资源。
/// 应在程序关闭时调用。
/// </summary>
public static void Shutdown()
{
if (_loadedModels.Count > 0)
{
ThreadSafeLogger.Log("正在释放所有YOLO模型...");
foreach (var yolo in _loadedModels.Values)
{
yolo?.Dispose();
}
_loadedModels.Clear();
ThreadSafeLogger.Log("所有YOLO模型已释放。");
}
}
}
}

28
Check.Main/Program.cs Normal file
View File

@@ -0,0 +1,28 @@
using System.Diagnostics;
namespace Check.Main
{
internal static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
var current = Process.GetCurrentProcess();
var others = Process.GetProcessesByName(current.ProcessName)
.Where(p => p.Id != current.Id);
if (others.Any())
{
MessageBox.Show("<22><><EFBFBD><EFBFBD><EFBFBD>Ѿ<EFBFBD><D1BE><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>У<EFBFBD><D0A3><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ظ<EFBFBD><D8B8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>", "<22><>ʾ",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
Application.Run(new FrmMain());
}
}
}

View File

@@ -0,0 +1,74 @@
using Check.Main.Common;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Check.Main.Result
{
/// <summary>
/// 代表一个待检测产品的类,存储来自多个相机的图像
/// </summary>
public class ProductResult
{
/// <summary>
/// 产品唯一ID可以是时间戳或触发计数
/// </summary>
public long ProductID { get; }
/// <summary>
/// 存储每个相机名称和其拍摄到的图像
/// </summary>
public Dictionary<string, Bitmap> CapturedImages { get; }
public ProductResult(long productID)
{
ProductID = productID;
CapturedImages = new Dictionary<string, Bitmap>();
}
/// <summary>
/// 添加一张某个相机拍摄的图像
/// </summary>
/// <param name="cameraName">相机名称</param>
/// <param name="image">拍摄的图像</param>
public void AddImage(string cameraName, Bitmap image)
{
if (!CapturedImages.ContainsKey(cameraName))
{
CapturedImages.Add(cameraName, image);
}
else
{
// 如果这个键已经存在,说明发生了逻辑错误。
// 我们不应该持有这个新的 image 对象,必须释放它以防泄漏。
ThreadSafeLogger.Log($"[警告] 相机 {cameraName} 为产品 #{this.ProductID} 发送了重复的图像。多余的图像将被丢弃。");
image?.Dispose();
}
}
/// <summary>
/// 检查是否所有预期的相机都已完成拍摄
/// </summary>
/// <param name="expectedCameraCount">预期的相机数量</param>
/// <returns></returns>
public bool IsComplete(int expectedCameraCount)
{
return CapturedImages.Count == expectedCameraCount;
}
/// <summary>
/// 释放所有图像资源,防止内存泄漏
/// </summary>
public void Dispose()
{
foreach (var image in CapturedImages.Values)
{
image?.Dispose();
}
CapturedImages.Clear();
}
}
}

113
Check.Main/UI/FormControlPanel.Designer.cs generated Normal file
View File

@@ -0,0 +1,113 @@
namespace Check.Main.UI
{
partial class FormControlPanel
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.uiTableLayoutPanel1 = new Sunny.UI.UITableLayoutPanel();
this.btnStartDevice = new Sunny.UI.UIButton();
this.btnStartCheck = new Sunny.UI.UIButton();
this.uiTableLayoutPanel1.SuspendLayout();
this.SuspendLayout();
//
// uiTableLayoutPanel1
//
this.uiTableLayoutPanel1.ColumnCount = 2;
this.uiTableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 50F));
this.uiTableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 50F));
this.uiTableLayoutPanel1.Controls.Add(this.btnStartDevice, 0, 1);
this.uiTableLayoutPanel1.Controls.Add(this.btnStartCheck, 0, 3);
this.uiTableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
this.uiTableLayoutPanel1.Location = new System.Drawing.Point(0, 0);
this.uiTableLayoutPanel1.Name = "uiTableLayoutPanel1";
this.uiTableLayoutPanel1.RowCount = 5;
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.38585F));
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 35.35353F));
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 7.575758F));
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.83838F));
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.38585F));
this.uiTableLayoutPanel1.Size = new System.Drawing.Size(230, 198);
this.uiTableLayoutPanel1.TabIndex = 0;
this.uiTableLayoutPanel1.TagString = null;
//
// btnStartDevice
//
this.uiTableLayoutPanel1.SetColumnSpan(this.btnStartDevice, 2);
this.btnStartDevice.Cursor = System.Windows.Forms.Cursors.Hand;
this.btnStartDevice.Dock = System.Windows.Forms.DockStyle.Fill;
this.btnStartDevice.FillPressColor = System.Drawing.Color.LimeGreen;
this.btnStartDevice.FillSelectedColor = System.Drawing.Color.LimeGreen;
this.btnStartDevice.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.btnStartDevice.Location = new System.Drawing.Point(3, 25);
this.btnStartDevice.MinimumSize = new System.Drawing.Size(1, 1);
this.btnStartDevice.Name = "btnStartDevice";
this.btnStartDevice.Size = new System.Drawing.Size(224, 64);
this.btnStartDevice.TabIndex = 0;
this.btnStartDevice.Text = "启动设备";
this.btnStartDevice.TipsFont = new System.Drawing.Font("宋体", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.btnStartDevice.Click += new System.EventHandler(this.btnStartDevice_Click);
//
// btnStartCheck
//
this.uiTableLayoutPanel1.SetColumnSpan(this.btnStartCheck, 2);
this.btnStartCheck.Cursor = System.Windows.Forms.Cursors.Hand;
this.btnStartCheck.Dock = System.Windows.Forms.DockStyle.Fill;
this.btnStartCheck.FillPressColor = System.Drawing.Color.FromArgb(((int)(((byte)(0)))), ((int)(((byte)(192)))), ((int)(((byte)(0)))));
this.btnStartCheck.FillSelectedColor = System.Drawing.Color.FromArgb(((int)(((byte)(0)))), ((int)(((byte)(192)))), ((int)(((byte)(0)))));
this.btnStartCheck.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.btnStartCheck.Location = new System.Drawing.Point(3, 110);
this.btnStartCheck.MinimumSize = new System.Drawing.Size(1, 1);
this.btnStartCheck.Name = "btnStartCheck";
this.btnStartCheck.Size = new System.Drawing.Size(224, 61);
this.btnStartCheck.TabIndex = 1;
this.btnStartCheck.Text = "开始检测";
this.btnStartCheck.TipsFont = new System.Drawing.Font("宋体", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.btnStartCheck.Click += new System.EventHandler(this.btnStartCheck_Click);
//
// FormControlPanel
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(230, 198);
this.ControlBox = false;
this.Controls.Add(this.uiTableLayoutPanel1);
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "FormControlPanel";
this.Text = "启动管理";
this.uiTableLayoutPanel1.ResumeLayout(false);
this.ResumeLayout(false);
}
#endregion
private Sunny.UI.UITableLayoutPanel uiTableLayoutPanel1;
private Sunny.UI.UIButton btnStartDevice;
private Sunny.UI.UIButton btnStartCheck;
}
}

View File

@@ -0,0 +1,264 @@
using Check.Main.Camera;
using Check.Main.Common;
using Check.Main.Infer;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using WeifenLuo.WinFormsUI.Docking;
namespace Check.Main.UI
{
public partial class FormControlPanel : DockContent
{
private bool _isDeviceReady = false; // 新的状态:设备是否已准备好
private bool _isDetecting = false; // 新的状态:是否正在检测中
// 用于跟踪设备运行状态的私有标志
private bool _isDeviceRunning = false;
public FormControlPanel()
{
InitializeComponent();
ConfigurationManager.OnConfigurationChanged += HandleConfigurationChanged;
UpdateUI();
}
/// <summary>
/// 处理全局配置在其他地方(如 FrmConfig被更改的事件
/// </summary>
private void HandleConfigurationChanged()
{
// 这是一个安全措施。如果设备正在运行时配置发生了变化,
// 最安全的做法是停止设备,以防止出现不可预知的行为。
if (_isDeviceRunning)
{
// 使用 Invoke 确保UI更新在正确的线程上执行
this.Invoke((Action)(() =>
{
ThreadSafeLogger.Log("相机配置已在运行时发生更改,设备将自动停止。请重新启动设备以应用新配置。");
//MessageBox.Show("相机配置已在运行时发生更改,设备将自动停止。请重新启动设备以应用新配置。",
// "配置变更", MessageBoxButtons.OK, MessageBoxIcon.Information);
// 触发与点击“关闭设备”按钮相同的逻辑
btnStartDevice_Click(this, EventArgs.Empty);
}));
}
}
private void btnStartDevice_Click(object sender, EventArgs e)
{
if (_isDeviceReady)//_isDeviceRunning
{
// --- 关闭流程 ---
ThreadSafeLogger.Log("用户点击“关闭设备”,开始完整关闭流程...");
// 如果正在检测,先停止它
if (_isDetecting)
{
btnStartCheck_Click(this, EventArgs.Empty); // 调用停止检测的逻辑
}
var mainForm = this.DockPanel.FindForm() as FrmMain;
CameraManager.Shutdown();
_isDeviceReady = false;
//// 1. 停止硬触发模拟器
//CameraManager.StopHardwareTriggerSimulator();
//// 2. 如果检测正在运行,则停止
//if (DetectionCoordinator.IsDetectionRunning)
//{
// DetectionCoordinator.StopDetection();
// UpdateDetectionButtonUI();
//}
// 3. 执行完整的系统关闭(包括相机硬件和检测协调器)
//CameraManager.Shutdown();
//YoloModelManager.Shutdown();
//_isDeviceRunning = false;
}
else
{
// --- 启动流程 ---
ThreadSafeLogger.Log("用户点击“启动设备”,开始新的启动流程...");
// 1. 从单一数据源获取完整的配置对象
var config = ConfigurationManager.GetCurrentConfig();
// 2. 验证相机配置的有效性
if (config.CameraSettings == null || !config.CameraSettings.Any(c => c.IsEnabled))
{
ThreadSafeLogger.Log("没有已启用的相机配置,启动中止。");
return;
}
// 3. 获取主窗体引用
var mainForm = this.DockPanel.FindForm() as FrmMain;
if (mainForm == null)
{
ThreadSafeLogger.Log("无法找到主窗体,启动中止。");
return;
}
// 4. 执行新的启动流程:
// 第一步初始化系统。这会准备好相机硬件、UI窗口和所有后台处理线程。
CameraManager.PrepareAll(config, mainForm);
_isDeviceReady = true;
//YoloModelManager.Initialize(config.ModelSettings);
//CameraManager.Initialize(config, mainForm);
//// 第二步:命令所有相机开始采集图像。
//CameraManager.StartAll();
//// 5. 如果有任何相机配置为软触发模式,我们启动模拟器来模拟触发信号
//if (config.CameraSettings.Any(c => c.IsEnabled && c.TriggerMode == TriggerModeType.Software))
//{
// ThreadSafeLogger.Log("检测到软触发相机,启动触发模拟器。");
// CameraManager.TriggerInterval = 100; // 根据需要设置间隔
// CameraManager.StartHardwareTriggerSimulator();
//}
//_isDeviceRunning = true;
}
UpdateUI();//UpdateDeviceButtonUI();
}
private void btnStartCheck_Click(object sender, EventArgs e)
{
if (_isDetecting)
{
// --- 停止检测 ---
ThreadSafeLogger.Log("用户点击“停止检测”,暂停数据流...");
// 停止硬触发模拟器
CameraManager.StopHardwareTriggerSimulator();
// 停止相机采集
CameraManager.StopAll();
// 停止统计
DetectionCoordinator.StopDetection();
_isDetecting = false;
}
else
{
// --- 开始检测 ---
ThreadSafeLogger.Log("用户点击“开始检测”,启动数据流...");
// 命令相机开始采集
CameraManager.StartAll();
// 启动硬触发模拟器(如果需要)
var config = ConfigurationManager.GetCurrentConfig();
if (config.CameraSettings.Any(c => c.IsEnabled && c.TriggerMode == TriggerModeType.Software))
{
CameraManager.TriggerInterval = 100;
CameraManager.StartHardwareTriggerSimulator();
}
// 开始统计
DetectionCoordinator.StartDetection();
_isDetecting = true;
}
UpdateUI();
//if (!_isDeviceRunning && !DetectionCoordinator.IsDetectionRunning)
//{
// ThreadSafeLogger.Log("设备未启动,无法开始检测。");
// return;
//}
//// 现在调用 DetectionCoordinator 中的方法
//if (DetectionCoordinator.IsDetectionRunning)
//{
// DetectionCoordinator.StopDetection();
//}
//else
//{
// DetectionCoordinator.StartDetection();
//}
//UpdateDetectionButtonUI();
}
#region UI
/// <summary>
/// 根据设备运行状态更新“设备”按钮的UI文本和颜色
/// </summary>
private void UpdateDeviceButtonUI()
{
if (_isDeviceRunning)
{
btnStartDevice.Text = "关闭设备";
btnStartDevice.BackColor = Color.Salmon;
}
else
{
btnStartDevice.Text = "启动设备";
btnStartDevice.BackColor = SystemColors.Control;
}
}
// 统一的UI更新方法
private void UpdateUI()
{
// --- 更新“设备”按钮 ---
if (_isDeviceReady)
{
btnStartDevice.Text = "关闭设备";
btnStartDevice.BackColor = Color.Salmon;
btnStartCheck.Enabled = true; // 设备就绪后,检测按钮才可用
}
else
{
btnStartDevice.Text = "启动设备";
btnStartDevice.BackColor = SystemColors.Control;
btnStartCheck.Enabled = false; // 设备未就绪,检测按钮不可用
}
// --- 更新“检测”按钮 ---
if (_isDetecting)
{
btnStartCheck.Text = "停止检测";
btnStartCheck.BackColor = Color.LightGreen;
btnStartDevice.Enabled = false; // 正在检测时,不允许关闭设备
}
else
{
btnStartCheck.Text = "开始检测";
btnStartCheck.BackColor = SystemColors.Control;
if (_isDeviceReady) btnStartDevice.Enabled = true; // 停止检测后,允许关闭设备
}
}
/// <summary>
/// 根据检测运行状态更新“检测”按钮的UI文本和颜色
/// </summary>
private void UpdateDetectionButtonUI()
{
//if (CameraManager.IsDetectionRunning)
//{
// btnStartCheck.Text = "停止检测";
// btnStartCheck.BackColor = Color.LightGreen;
//}
//else
//{
// btnStartCheck.Text = "启动检测";
// btnStartCheck.BackColor = SystemColors.Control;
//}
if (DetectionCoordinator.IsDetectionRunning)
{
btnStartCheck.Text = "停止检测";
btnStartCheck.BackColor = Color.LightGreen;
}
else
{
btnStartCheck.Text = "启动检测";
btnStartCheck.BackColor = SystemColors.Control;
}
}
#endregion
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -0,0 +1,46 @@
namespace Check.Main.UI
{
partial class FormImageDisplay
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.SuspendLayout();
//
// FormImageDisplay
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(508, 290);
this.Name = "FormImageDisplay";
this.Text = "FormImageDisplay";
this.ResumeLayout(false);
}
#endregion
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using WeifenLuo.WinFormsUI.Docking;
namespace Check.Main.UI
{
public partial class FormImageDisplay : DockContent
{
/// <summary>
/// 相机名称
/// </summary>
public string CameraName { get; set; }
/// <summary>
/// 当此显示窗口发生特定事件时如ROI裁剪触发此事件以通知外部如日志系统
/// </summary>
public event Action<string> OnDisplayEvent;
// 使用我们全新的自定义控件
private ZoomPictureBox zoomPictureBox;
public FormImageDisplay()
{
InitializeComponent();
// 实例化新的控件
zoomPictureBox = new ZoomPictureBox
{
Dock = DockStyle.Fill,
// 其他属性可以在这里设置,例如
// RectangleColor = Color.LawnGreen,
// BackgroundFillColor = Color.FromArgb(45, 45, 48)
};
this.Controls.Add(zoomPictureBox);
// 订阅自定义控件的ROI裁剪完成事件
zoomPictureBox.CroppingEnabled = false;
//zoomPictureBox.Cropped += ZoomPictureBox_Cropped;
}
/// <summary>
/// 更新显示的图像(线程安全)。
/// 此方法现在将图像设置到 ZoomPictureBox1 控件中。
/// </summary>
/// <param name="image">从相机事件传来的原始Bitmap</param>
public void UpdateImage(Bitmap image)
{
if (zoomPictureBox != null && !zoomPictureBox.IsDisposed)
{
zoomPictureBox.SetImageThreadSafe(image);
}
else
{
// 如果PictureBox已经被释放那么我们也应该释放这个多余的图像
image?.Dispose();
}
}
// 重写 Close 方法,确保在窗口关闭时,内部的控件和资源也能被妥善处理
public new void Close()
{
// 取消事件订阅,防止内存泄漏
if (zoomPictureBox != null)
{
//zoomPictureBox.Cropped -= ZoomPictureBox_Cropped;
}
base.Close();
}
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

245
Check.Main/UI/FormStatistics.Designer.cs generated Normal file
View File

@@ -0,0 +1,245 @@
namespace Check.Main.UI
{
partial class FormStatistics
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(FormStatistics));
this.uiTableLayoutPanel1 = new Sunny.UI.UITableLayoutPanel();
this.toolStrip1 = new System.Windows.Forms.ToolStrip();
this.uiLabel1 = new Sunny.UI.UILabel();
this.uiLabel2 = new Sunny.UI.UILabel();
this.uiLabel3 = new Sunny.UI.UILabel();
this.uiLabel4 = new Sunny.UI.UILabel();
this.txtOKNum = new Sunny.UI.UITextBox();
this.txtNGNum = new Sunny.UI.UITextBox();
this.txtTotal = new Sunny.UI.UITextBox();
this.txtYieldRate = new Sunny.UI.UITextBox();
this.toolStripButtonRest = new System.Windows.Forms.ToolStripButton();
this.uiTableLayoutPanel1.SuspendLayout();
this.toolStrip1.SuspendLayout();
this.SuspendLayout();
//
// uiTableLayoutPanel1
//
this.uiTableLayoutPanel1.ColumnCount = 2;
this.uiTableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 45.88745F));
this.uiTableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 54.11255F));
this.uiTableLayoutPanel1.Controls.Add(this.txtYieldRate, 1, 3);
this.uiTableLayoutPanel1.Controls.Add(this.txtTotal, 1, 2);
this.uiTableLayoutPanel1.Controls.Add(this.txtNGNum, 1, 1);
this.uiTableLayoutPanel1.Controls.Add(this.toolStrip1, 0, 4);
this.uiTableLayoutPanel1.Controls.Add(this.uiLabel1, 0, 0);
this.uiTableLayoutPanel1.Controls.Add(this.uiLabel2, 0, 1);
this.uiTableLayoutPanel1.Controls.Add(this.uiLabel3, 0, 2);
this.uiTableLayoutPanel1.Controls.Add(this.uiLabel4, 0, 3);
this.uiTableLayoutPanel1.Controls.Add(this.txtOKNum, 1, 0);
this.uiTableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
this.uiTableLayoutPanel1.Location = new System.Drawing.Point(0, 0);
this.uiTableLayoutPanel1.Name = "uiTableLayoutPanel1";
this.uiTableLayoutPanel1.RowCount = 5;
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.uiTableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.uiTableLayoutPanel1.Size = new System.Drawing.Size(231, 228);
this.uiTableLayoutPanel1.TabIndex = 0;
this.uiTableLayoutPanel1.TagString = null;
//
// toolStrip1
//
this.uiTableLayoutPanel1.SetColumnSpan(this.toolStrip1, 2);
this.toolStrip1.Dock = System.Windows.Forms.DockStyle.Fill;
this.toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.toolStripButtonRest});
this.toolStrip1.Location = new System.Drawing.Point(0, 180);
this.toolStrip1.Name = "toolStrip1";
this.toolStrip1.Size = new System.Drawing.Size(231, 48);
this.toolStrip1.TabIndex = 0;
this.toolStrip1.Text = "toolStrip1";
//
// uiLabel1
//
this.uiLabel1.Dock = System.Windows.Forms.DockStyle.Fill;
this.uiLabel1.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.uiLabel1.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(48)))), ((int)(((byte)(48)))), ((int)(((byte)(48)))));
this.uiLabel1.Location = new System.Drawing.Point(3, 0);
this.uiLabel1.Name = "uiLabel1";
this.uiLabel1.Size = new System.Drawing.Size(100, 45);
this.uiLabel1.TabIndex = 1;
this.uiLabel1.Text = "良品数";
this.uiLabel1.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
//
// uiLabel2
//
this.uiLabel2.Dock = System.Windows.Forms.DockStyle.Fill;
this.uiLabel2.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.uiLabel2.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(48)))), ((int)(((byte)(48)))), ((int)(((byte)(48)))));
this.uiLabel2.Location = new System.Drawing.Point(3, 45);
this.uiLabel2.Name = "uiLabel2";
this.uiLabel2.Size = new System.Drawing.Size(100, 45);
this.uiLabel2.TabIndex = 2;
this.uiLabel2.Text = "不良品数量";
this.uiLabel2.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
//
// uiLabel3
//
this.uiLabel3.Dock = System.Windows.Forms.DockStyle.Fill;
this.uiLabel3.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.uiLabel3.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(48)))), ((int)(((byte)(48)))), ((int)(((byte)(48)))));
this.uiLabel3.Location = new System.Drawing.Point(3, 90);
this.uiLabel3.Name = "uiLabel3";
this.uiLabel3.Size = new System.Drawing.Size(100, 45);
this.uiLabel3.TabIndex = 3;
this.uiLabel3.Text = "总数";
this.uiLabel3.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
//
// uiLabel4
//
this.uiLabel4.Dock = System.Windows.Forms.DockStyle.Fill;
this.uiLabel4.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.uiLabel4.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(48)))), ((int)(((byte)(48)))), ((int)(((byte)(48)))));
this.uiLabel4.Location = new System.Drawing.Point(3, 135);
this.uiLabel4.Name = "uiLabel4";
this.uiLabel4.Size = new System.Drawing.Size(100, 45);
this.uiLabel4.TabIndex = 4;
this.uiLabel4.Text = "良品率";
this.uiLabel4.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
//
// txtOKNum
//
this.txtOKNum.Cursor = System.Windows.Forms.Cursors.IBeam;
this.txtOKNum.Dock = System.Windows.Forms.DockStyle.Fill;
this.txtOKNum.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.txtOKNum.Location = new System.Drawing.Point(110, 5);
this.txtOKNum.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5);
this.txtOKNum.MinimumSize = new System.Drawing.Size(1, 16);
this.txtOKNum.Name = "txtOKNum";
this.txtOKNum.Padding = new System.Windows.Forms.Padding(5);
this.txtOKNum.ShowText = false;
this.txtOKNum.Size = new System.Drawing.Size(117, 35);
this.txtOKNum.TabIndex = 5;
this.txtOKNum.Text = "0";
this.txtOKNum.TextAlignment = System.Drawing.ContentAlignment.MiddleCenter;
this.txtOKNum.Watermark = "";
//
// txtNGNum
//
this.txtNGNum.Cursor = System.Windows.Forms.Cursors.IBeam;
this.txtNGNum.Dock = System.Windows.Forms.DockStyle.Fill;
this.txtNGNum.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.txtNGNum.Location = new System.Drawing.Point(110, 50);
this.txtNGNum.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5);
this.txtNGNum.MinimumSize = new System.Drawing.Size(1, 16);
this.txtNGNum.Name = "txtNGNum";
this.txtNGNum.Padding = new System.Windows.Forms.Padding(5);
this.txtNGNum.ShowText = false;
this.txtNGNum.Size = new System.Drawing.Size(117, 35);
this.txtNGNum.TabIndex = 6;
this.txtNGNum.Text = "0";
this.txtNGNum.TextAlignment = System.Drawing.ContentAlignment.MiddleCenter;
this.txtNGNum.Watermark = "";
//
// txtTotal
//
this.txtTotal.Cursor = System.Windows.Forms.Cursors.IBeam;
this.txtTotal.Dock = System.Windows.Forms.DockStyle.Fill;
this.txtTotal.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.txtTotal.Location = new System.Drawing.Point(110, 95);
this.txtTotal.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5);
this.txtTotal.MinimumSize = new System.Drawing.Size(1, 16);
this.txtTotal.Name = "txtTotal";
this.txtTotal.Padding = new System.Windows.Forms.Padding(5);
this.txtTotal.ShowText = false;
this.txtTotal.Size = new System.Drawing.Size(117, 35);
this.txtTotal.TabIndex = 7;
this.txtTotal.Text = "0";
this.txtTotal.TextAlignment = System.Drawing.ContentAlignment.MiddleCenter;
this.txtTotal.Watermark = "";
//
// txtYieldRate
//
this.txtYieldRate.Cursor = System.Windows.Forms.Cursors.IBeam;
this.txtYieldRate.Dock = System.Windows.Forms.DockStyle.Fill;
this.txtYieldRate.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.txtYieldRate.Location = new System.Drawing.Point(110, 140);
this.txtYieldRate.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5);
this.txtYieldRate.MinimumSize = new System.Drawing.Size(1, 16);
this.txtYieldRate.Name = "txtYieldRate";
this.txtYieldRate.Padding = new System.Windows.Forms.Padding(5);
this.txtYieldRate.ShowText = false;
this.txtYieldRate.Size = new System.Drawing.Size(117, 35);
this.txtYieldRate.TabIndex = 8;
this.txtYieldRate.Text = "0";
this.txtYieldRate.TextAlignment = System.Drawing.ContentAlignment.MiddleCenter;
this.txtYieldRate.Watermark = "";
//
// toolStripButtonRest
//
this.toolStripButtonRest.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
this.toolStripButtonRest.Image = ((System.Drawing.Image)(resources.GetObject("toolStripButtonRest.Image")));
this.toolStripButtonRest.ImageTransparentColor = System.Drawing.Color.Magenta;
this.toolStripButtonRest.Name = "toolStripButtonRest";
this.toolStripButtonRest.Size = new System.Drawing.Size(60, 45);
this.toolStripButtonRest.Text = "重置计数";
this.toolStripButtonRest.Click += new System.EventHandler(this.toolStripButtonRest_Click);
//
// FormStatistics
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(231, 228);
this.ControlBox = false;
this.Controls.Add(this.uiTableLayoutPanel1);
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "FormStatistics";
this.Text = "统计";
this.uiTableLayoutPanel1.ResumeLayout(false);
this.uiTableLayoutPanel1.PerformLayout();
this.toolStrip1.ResumeLayout(false);
this.toolStrip1.PerformLayout();
this.ResumeLayout(false);
}
#endregion
private Sunny.UI.UITableLayoutPanel uiTableLayoutPanel1;
private System.Windows.Forms.ToolStrip toolStrip1;
private Sunny.UI.UITextBox txtYieldRate;
private Sunny.UI.UITextBox txtTotal;
private Sunny.UI.UITextBox txtNGNum;
private System.Windows.Forms.ToolStripButton toolStripButtonRest;
private Sunny.UI.UILabel uiLabel1;
private Sunny.UI.UILabel uiLabel2;
private Sunny.UI.UILabel uiLabel3;
private Sunny.UI.UILabel uiLabel4;
private Sunny.UI.UITextBox txtOKNum;
}
}

View File

@@ -0,0 +1,89 @@
using Check.Main.Camera;
using Check.Main.Common;
using Check.Main.Infer;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using WeifenLuo.WinFormsUI.Docking;
namespace Check.Main.UI
{
public partial class FormStatistics : DockContent
{
private readonly StatisticsData _statisticsData;
/// <summary>
/// 将私有字段通过公共属性暴露以便外部如FormMain访问。
/// </summary>
public StatisticsData CurrentStatistics => _statisticsData;
public FormStatistics()
{
InitializeComponent();
_statisticsData = new StatisticsData();
// 订阅CameraManager的检测完成事件
DetectionCoordinator.OnDetectionCompleted += CameraManager_OnDetectionCompleted;
// 初始化UI显示
UpdateUI();
}
// 当CameraManager发布检测结果时此方法被调用
private void CameraManager_OnDetectionCompleted(object sender, DetectionResultEventArgs e)
{
// 更新统计数据
_statisticsData.UpdateWithResult(e.IsOK);
// 在UI线程上更新界面显示
UpdateUI();
}
// 更新所有标签的文本
private void UpdateUI()
{
// 使用Invoke确保线程安全
if (this.InvokeRequired)
{
this.Invoke(new Action(UpdateUI));
return;
}
txtOKNum.Text = _statisticsData.GoodCount.ToString();
txtNGNum.Text = _statisticsData.NgCount.ToString();
txtTotal.Text = _statisticsData.TotalCount.ToString();
// 将良率格式化为百分比
txtYieldRate.Text = _statisticsData.YieldRate.ToString("P2", CultureInfo.InvariantCulture);
// 根据良率改变颜色以示提醒
if (_statisticsData.YieldRate < 0.9 && _statisticsData.TotalCount > 10)
{
txtYieldRate.ForeColor = Color.Red;
}
else
{
txtYieldRate.ForeColor = Color.ForestGreen;
}
}
private void toolStripButtonRest_Click(object sender, EventArgs e)
{
StatisticsExporter.ExportToExcel(CurrentStatistics, "Reset");
_statisticsData.Reset();
// 同时重置产品ID计数器
DetectionCoordinator.ResetAllCounters();
UpdateUI();
ThreadSafeLogger.Log("统计数据已重置。");
}
// 窗体关闭时,取消事件订阅,防止内存泄漏
protected override void OnFormClosing(FormClosingEventArgs e)
{
DetectionCoordinator.OnDetectionCompleted -= CameraManager_OnDetectionCompleted;
base.OnFormClosing(e);
}
}
}

View File

@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="toolStripButtonRest.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIDSURBVDhPpZLrS5NhGMb3j4SWh0oRQVExD4gonkDpg4hG
YKxG6WBogkMZKgPNCEVJFBGdGETEvgwyO9DJE5syZw3PIlPEE9pgBCLZ5XvdMB8Ew8gXbl54nuf63dd9
0OGSnwCahxbPRNPAPMw9Xpg6ZmF46kZZ0xSKzJPIrhpDWsVnpBhGkKx3nAX8Pv7z1zg8OoY/cITdn4fw
bf/C0kYAN3Ma/w3gWfZL5kzTKBxjWyK2DftwI9tyMYCZKXbNHaD91bLYJrDXsYbrWfUKwJrPE9M2M1Oc
VzOOpHI7Jr376Hi9ogHqFIANO0/MmmmbmSmm9a8ze+I4MrNWAdjtoJgWcx+PSzg166yZZ8xM8XvXDix9
c4jIqFYAjoriBV9AhEPv1mH/sonogha0afbZMMZz+yreTGyhpusHwtNNCsA5U1zS4BLxzJIfg299qO32
Ir7UJtZfftyATqeT+8o2D8JSjQrAJblrncYL7ZJ2+bfaFnC/1S1NjL3diRat7qrO7wLRP3HjWsojBeCo
mDEo5mNjuweFGvjWg2EBhCbpkW78htSHHwRyNdmgAFzPEee2iFkzayy2OLXzT4gr6UdUnlXrullsxxQ+
kx0g8BTA3aZlButjSTyjODq/WcQcW/B/Je4OQhLvKQDnzN1mp0nnkvAhR8VuMzNrpm1mpjgkoVwB/v8D
TgDQASA1MVpwzwAAAABJRU5ErkJggg==
</value>
</data>
</root>

175
Check.Main/UI/FrmCamConfig.Designer.cs generated Normal file
View File

@@ -0,0 +1,175 @@
namespace Check.Main.UI
{
partial class FrmCamConfig
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(FrmCamConfig));
tableLayoutPanel1 = new TableLayoutPanel();
toolStrip1 = new ToolStrip();
toolBtnAdd = new ToolStripButton();
toolBtnRemove = new ToolStripButton();
toolBtnSet = new ToolStripButton();
splitContainer1 = new SplitContainer();
listBoxCameras = new ListBox();
propertyGrid1 = new PropertyGrid();
tableLayoutPanel1.SuspendLayout();
toolStrip1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit();
splitContainer1.Panel1.SuspendLayout();
splitContainer1.Panel2.SuspendLayout();
splitContainer1.SuspendLayout();
SuspendLayout();
//
// tableLayoutPanel1
//
tableLayoutPanel1.ColumnCount = 1;
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
tableLayoutPanel1.Controls.Add(toolStrip1, 0, 0);
tableLayoutPanel1.Controls.Add(splitContainer1, 0, 1);
tableLayoutPanel1.Dock = DockStyle.Fill;
tableLayoutPanel1.Location = new Point(0, 0);
tableLayoutPanel1.Margin = new Padding(4, 4, 4, 4);
tableLayoutPanel1.Name = "tableLayoutPanel1";
tableLayoutPanel1.RowCount = 2;
tableLayoutPanel1.RowStyles.Add(new RowStyle());
tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
tableLayoutPanel1.Size = new Size(632, 560);
tableLayoutPanel1.TabIndex = 0;
//
// toolStrip1
//
toolStrip1.Items.AddRange(new ToolStripItem[] { toolBtnAdd, toolBtnRemove, toolBtnSet });
toolStrip1.Location = new Point(0, 0);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new Size(632, 25);
toolStrip1.TabIndex = 0;
toolStrip1.Text = "toolStrip1";
//
// toolBtnAdd
//
toolBtnAdd.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnAdd.Image = (Image)resources.GetObject("toolBtnAdd.Image");
toolBtnAdd.ImageTransparentColor = Color.Magenta;
toolBtnAdd.Name = "toolBtnAdd";
toolBtnAdd.Size = new Size(36, 22);
toolBtnAdd.Text = "添加";
toolBtnAdd.Click += toolBtnAdd_Click;
//
// toolBtnRemove
//
toolBtnRemove.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnRemove.Image = (Image)resources.GetObject("toolBtnRemove.Image");
toolBtnRemove.ImageTransparentColor = Color.Magenta;
toolBtnRemove.Name = "toolBtnRemove";
toolBtnRemove.Size = new Size(36, 22);
toolBtnRemove.Text = "移除";
toolBtnRemove.Click += toolBtnRemove_Click;
//
// toolBtnSet
//
toolBtnSet.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnSet.Image = (Image)resources.GetObject("toolBtnSet.Image");
toolBtnSet.ImageTransparentColor = Color.Magenta;
toolBtnSet.Name = "toolBtnSet";
toolBtnSet.Size = new Size(60, 22);
toolBtnSet.Text = "应用配置";
toolBtnSet.Click += toolBtnSet_Click;
//
// splitContainer1
//
splitContainer1.Dock = DockStyle.Fill;
splitContainer1.Location = new Point(4, 29);
splitContainer1.Margin = new Padding(4, 4, 4, 4);
splitContainer1.Name = "splitContainer1";
//
// splitContainer1.Panel1
//
splitContainer1.Panel1.Controls.Add(listBoxCameras);
//
// splitContainer1.Panel2
//
splitContainer1.Panel2.Controls.Add(propertyGrid1);
splitContainer1.Size = new Size(624, 527);
splitContainer1.SplitterDistance = 210;
splitContainer1.SplitterWidth = 5;
splitContainer1.TabIndex = 1;
//
// listBoxCameras
//
listBoxCameras.Dock = DockStyle.Fill;
listBoxCameras.FormattingEnabled = true;
listBoxCameras.ItemHeight = 17;
listBoxCameras.Location = new Point(0, 0);
listBoxCameras.Margin = new Padding(4, 4, 4, 4);
listBoxCameras.Name = "listBoxCameras";
listBoxCameras.Size = new Size(210, 527);
listBoxCameras.TabIndex = 0;
listBoxCameras.SelectedIndexChanged += listBoxCameras_SelectedIndexChanged;
//
// propertyGrid1
//
propertyGrid1.Dock = DockStyle.Fill;
propertyGrid1.Location = new Point(0, 0);
propertyGrid1.Margin = new Padding(4, 4, 4, 4);
propertyGrid1.Name = "propertyGrid1";
propertyGrid1.Size = new Size(409, 527);
propertyGrid1.TabIndex = 0;
//
// FrmCamConfig
//
AutoScaleDimensions = new SizeF(7F, 17F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(632, 560);
Controls.Add(tableLayoutPanel1);
Margin = new Padding(4, 4, 4, 4);
Name = "FrmCamConfig";
Text = "属性配置";
tableLayoutPanel1.ResumeLayout(false);
tableLayoutPanel1.PerformLayout();
toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout();
splitContainer1.Panel1.ResumeLayout(false);
splitContainer1.Panel2.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)splitContainer1).EndInit();
splitContainer1.ResumeLayout(false);
ResumeLayout(false);
}
#endregion
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
private System.Windows.Forms.ToolStrip toolStrip1;
private System.Windows.Forms.SplitContainer splitContainer1;
private System.Windows.Forms.ListBox listBoxCameras;
private System.Windows.Forms.PropertyGrid propertyGrid1;
private System.Windows.Forms.ToolStripButton toolBtnAdd;
private System.Windows.Forms.ToolStripButton toolBtnRemove;
private System.Windows.Forms.ToolStripButton toolBtnSet;
}
}

View File

@@ -0,0 +1,193 @@
using Check.Main.Camera;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Xml.Serialization;
using WeifenLuo.WinFormsUI.Docking;
namespace Check.Main.UI
{
public partial class FrmCamConfig : DockContent
{
// public List<CameraSettings> _settingsList = new List<CameraSettings>();
public List<CameraSettings> _settingsList { get; private set; }
private readonly string _configFilePath = Path.Combine(Application.StartupPath, "cameras.xml");
public FrmCamConfig()
{
InitializeComponent();
_settingsList = new List<CameraSettings>();
LoadSettings();
RefreshListBox();
AdaptForDialogMode(); // 调整UI为对话框模式
}
/// <summary>
/// 用于从外部接收一个设置列表进行编辑。
/// </summary>
/// <param name="settingsToEdit">要编辑的相机设置列表</param>
public FrmCamConfig(List<CameraSettings> settingsToEdit)
{
InitializeComponent();
// 创建一个现有列表的副本进行编辑,这样如果用户点“取消”,原始列表不会被影响
_settingsList = settingsToEdit != null
? settingsToEdit.Select(s => s.Clone() as CameraSettings).ToList() // 深度克隆更好,这里为简化用浅克隆
: new List<CameraSettings>();
// 别忘了为列表中的每个对象重新订阅事件
foreach (var setting in _settingsList)
{
setting.PropertyChanged += Setting_PropertyChanged;
}
RefreshListBox();
AdaptForDialogMode(); // 调整UI为对话框模式
}
/// <summary>
/// 【新增的方法】
/// 调整UI使其更像一个对话框。
/// </summary>
private void AdaptForDialogMode()
{
// 将“应用配置”按钮改为“确定”
toolBtnSet.Text = "确定";
toolBtnSet.ToolTipText = "保存更改并关闭窗口";
// 新增一个“取消”按钮
var toolBtnCancel = new ToolStripButton("取消")
{
DisplayStyle = ToolStripItemDisplayStyle.Text,
Alignment = ToolStripItemAlignment.Right // 靠右对齐
};
toolBtnCancel.Click += (sender, e) => {
this.DialogResult = DialogResult.Cancel;
this.Close();
};
toolStrip1.Items.Add(toolBtnCancel);
}
/// <summary>
/// 【新增的事件处理方法】
/// 当PropertyGrid中的属性值被用户修改后触发此事件。
/// </summary>
// 刷新左侧的相机列表
private void RefreshListBox()
{
listBoxCameras.Items.Clear();
foreach (var setting in _settingsList)
{
listBoxCameras.Items.Add(setting.Name);
}
}
/// <summary>
/// 【全新的事件处理方法】
/// 监听单个CameraSettings对象的属性变更通知。
/// </summary>
private void Setting_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
// 检查是不是“Name”属性发生了变化
if (e.PropertyName == nameof(CameraSettings.Name))
{
// 'sender' 就是那个属性发生了变化的 CameraSettings 对象
var changedSetting = sender as CameraSettings;
if (changedSetting == null) return;
// 在 _settingsList 中找到这个对象的索引
int index = _settingsList.IndexOf(changedSetting);
// 如果找到了就更新ListBox中对应项的显示文本
if (index >= 0)
{
// 使用Invoke确保线程安全虽然在此场景下通常不是问题但这是个好习惯
this.Invoke(new Action(() => {
listBoxCameras.Items[index] = changedSetting.Name;
}));
}
}
}
private void listBoxCameras_SelectedIndexChanged(object sender, EventArgs e)
{
if (listBoxCameras.SelectedIndex >= 0)
{
propertyGrid1.SelectedObject = _settingsList[listBoxCameras.SelectedIndex];
}
else
{
propertyGrid1.SelectedObject = null;
}
}
private void toolBtnAdd_Click(object sender, EventArgs e)
{
var newSetting = new CameraSettings { Name = $"Camera-{_settingsList.Count + 1}" };
_settingsList.Add(newSetting);
newSetting.PropertyChanged += Setting_PropertyChanged;
RefreshListBox();
listBoxCameras.SelectedIndex = listBoxCameras.Items.Count - 1;
}
private void toolBtnRemove_Click(object sender, EventArgs e)
{
if (listBoxCameras.SelectedIndex >= 0)
{
_settingsList.RemoveAt(listBoxCameras.SelectedIndex);
propertyGrid1.SelectedObject = null;
RefreshListBox();
}
}
private void toolBtnSet_Click(object sender, EventArgs e)
{
this.DialogResult = DialogResult.OK;
this.Close();
}
// 加载XML配置文件
private void LoadSettings()
{
if (!File.Exists(_configFilePath)) return;
try
{
XmlSerializer serializer = new XmlSerializer(typeof(List<CameraSettings>));
using (FileStream fs = new FileStream(_configFilePath, FileMode.Open))
{
_settingsList = (List<CameraSettings>)serializer.Deserialize(fs);
}
}
catch (Exception ex)
{
MessageBox.Show("加载相机配置失败: " + ex.Message);
}
foreach (var setting in _settingsList)
{
setting.PropertyChanged += Setting_PropertyChanged;
}
}
// 保存配置到XML文件
private void SaveSettings()
{
try
{
XmlSerializer serializer = new XmlSerializer(typeof(List<CameraSettings>));
using (FileStream fs = new FileStream(_configFilePath, FileMode.Create))
{
serializer.Serialize(fs, _settingsList);
}
}
catch (Exception ex)
{
MessageBox.Show("保存相机配置失败: " + ex.Message);
}
}
}
}

View File

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="toolBtnAdd.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIDSURBVDhPpZLrS5NhGMb3j4SWh0oRQVExD4gonkDpg4hG
YKxG6WBogkMZKgPNCEVJFBGdGETEvgwyO9DJE5syZw3PIlPEE9pgBCLZ5XvdMB8Ew8gXbl54nuf63dd9
0OGSnwCahxbPRNPAPMw9Xpg6ZmF46kZZ0xSKzJPIrhpDWsVnpBhGkKx3nAX8Pv7z1zg8OoY/cITdn4fw
bf/C0kYAN3Ma/w3gWfZL5kzTKBxjWyK2DftwI9tyMYCZKXbNHaD91bLYJrDXsYbrWfUKwJrPE9M2M1Oc
VzOOpHI7Jr376Hi9ogHqFIANO0/MmmmbmSmm9a8ze+I4MrNWAdjtoJgWcx+PSzg166yZZ8xM8XvXDix9
c4jIqFYAjoriBV9AhEPv1mH/sonogha0afbZMMZz+yreTGyhpusHwtNNCsA5U1zS4BLxzJIfg299qO32
Ir7UJtZfftyATqeT+8o2D8JSjQrAJblrncYL7ZJ2+bfaFnC/1S1NjL3diRat7qrO7wLRP3HjWsojBeCo
mDEo5mNjuweFGvjWg2EBhCbpkW78htSHHwRyNdmgAFzPEee2iFkzayy2OLXzT4gr6UdUnlXrullsxxQ+
kx0g8BTA3aZlButjSTyjODq/WcQcW/B/Je4OQhLvKQDnzN1mp0nnkvAhR8VuMzNrpm1mpjgkoVwB/v8D
TgDQASA1MVpwzwAAAABJRU5ErkJggg==
</value>
</data>
<data name="toolBtnRemove.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIDSURBVDhPpZLrS5NhGMb3j4SWh0oRQVExD4gonkDpg4hG
YKxG6WBogkMZKgPNCEVJFBGdGETEvgwyO9DJE5syZw3PIlPEE9pgBCLZ5XvdMB8Ew8gXbl54nuf63dd9
0OGSnwCahxbPRNPAPMw9Xpg6ZmF46kZZ0xSKzJPIrhpDWsVnpBhGkKx3nAX8Pv7z1zg8OoY/cITdn4fw
bf/C0kYAN3Ma/w3gWfZL5kzTKBxjWyK2DftwI9tyMYCZKXbNHaD91bLYJrDXsYbrWfUKwJrPE9M2M1Oc
VzOOpHI7Jr376Hi9ogHqFIANO0/MmmmbmSmm9a8ze+I4MrNWAdjtoJgWcx+PSzg166yZZ8xM8XvXDix9
c4jIqFYAjoriBV9AhEPv1mH/sonogha0afbZMMZz+yreTGyhpusHwtNNCsA5U1zS4BLxzJIfg299qO32
Ir7UJtZfftyATqeT+8o2D8JSjQrAJblrncYL7ZJ2+bfaFnC/1S1NjL3diRat7qrO7wLRP3HjWsojBeCo
mDEo5mNjuweFGvjWg2EBhCbpkW78htSHHwRyNdmgAFzPEee2iFkzayy2OLXzT4gr6UdUnlXrullsxxQ+
kx0g8BTA3aZlButjSTyjODq/WcQcW/B/Je4OQhLvKQDnzN1mp0nnkvAhR8VuMzNrpm1mpjgkoVwB/v8D
TgDQASA1MVpwzwAAAABJRU5ErkJggg==
</value>
</data>
<data name="toolBtnSet.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIDSURBVDhPpZLrS5NhGMb3j4SWh0oRQVExD4gonkDpg4hG
YKxG6WBogkMZKgPNCEVJFBGdGETEvgwyO9DJE5syZw3PIlPEE9pgBCLZ5XvdMB8Ew8gXbl54nuf63dd9
0OGSnwCahxbPRNPAPMw9Xpg6ZmF46kZZ0xSKzJPIrhpDWsVnpBhGkKx3nAX8Pv7z1zg8OoY/cITdn4fw
bf/C0kYAN3Ma/w3gWfZL5kzTKBxjWyK2DftwI9tyMYCZKXbNHaD91bLYJrDXsYbrWfUKwJrPE9M2M1Oc
VzOOpHI7Jr376Hi9ogHqFIANO0/MmmmbmSmm9a8ze+I4MrNWAdjtoJgWcx+PSzg166yZZ8xM8XvXDix9
c4jIqFYAjoriBV9AhEPv1mH/sonogha0afbZMMZz+yreTGyhpusHwtNNCsA5U1zS4BLxzJIfg299qO32
Ir7UJtZfftyATqeT+8o2D8JSjQrAJblrncYL7ZJ2+bfaFnC/1S1NjL3diRat7qrO7wLRP3HjWsojBeCo
mDEo5mNjuweFGvjWg2EBhCbpkW78htSHHwRyNdmgAFzPEee2iFkzayy2OLXzT4gr6UdUnlXrullsxxQ+
kx0g8BTA3aZlButjSTyjODq/WcQcW/B/Je4OQhLvKQDnzN1mp0nnkvAhR8VuMzNrpm1mpjgkoVwB/v8D
TgDQASA1MVpwzwAAAABJRU5ErkJggg==
</value>
</data>
</root>

171
Check.Main/UI/FrmConfig.Designer.cs generated Normal file
View File

@@ -0,0 +1,171 @@
namespace Check.Main.UI
{
partial class FrmConfig
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(FrmConfig));
propertyGrid1 = new PropertyGrid();
toolStrip1 = new ToolStrip();
toolBtnApply = new ToolStripButton();
toolBtnAddProduct = new ToolStripButton();
tableLayoutPanel1 = new TableLayoutPanel();
uiLabel1 = new Sunny.UI.UILabel();
cmbProducts = new Sunny.UI.UIComboBox();
toolBtnDeleteProduct = new ToolStripButton();
toolStrip1.SuspendLayout();
tableLayoutPanel1.SuspendLayout();
SuspendLayout();
//
// propertyGrid1
//
tableLayoutPanel1.SetColumnSpan(propertyGrid1, 2);
propertyGrid1.Dock = DockStyle.Fill;
propertyGrid1.Location = new Point(3, 70);
propertyGrid1.Name = "propertyGrid1";
propertyGrid1.Size = new Size(417, 345);
propertyGrid1.TabIndex = 0;
//
// toolStrip1
//
tableLayoutPanel1.SetColumnSpan(toolStrip1, 2);
toolStrip1.Dock = DockStyle.Fill;
toolStrip1.Items.AddRange(new ToolStripItem[] { toolBtnApply, toolBtnAddProduct, toolBtnDeleteProduct });
toolStrip1.Location = new Point(0, 0);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new Size(423, 28);
toolStrip1.TabIndex = 1;
toolStrip1.Text = "toolStrip1";
//
// toolBtnApply
//
toolBtnApply.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnApply.Image = (Image)resources.GetObject("toolBtnApply.Image");
toolBtnApply.ImageTransparentColor = Color.Magenta;
toolBtnApply.Name = "toolBtnApply";
toolBtnApply.Size = new Size(60, 25);
toolBtnApply.Text = "应用配置";
toolBtnApply.Click += toolBtnApply_Click;
//
// toolBtnAddProduct
//
toolBtnAddProduct.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnAddProduct.Image = (Image)resources.GetObject("toolBtnAddProduct.Image");
toolBtnAddProduct.ImageTransparentColor = Color.Magenta;
toolBtnAddProduct.Name = "toolBtnAddProduct";
toolBtnAddProduct.Size = new Size(60, 25);
toolBtnAddProduct.Text = "添加产品";
toolBtnAddProduct.Click += toolBtnAddProduct_Click;
//
// tableLayoutPanel1
//
tableLayoutPanel1.ColumnCount = 2;
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 23.4042549F));
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 76.59574F));
tableLayoutPanel1.Controls.Add(propertyGrid1, 0, 2);
tableLayoutPanel1.Controls.Add(toolStrip1, 0, 0);
tableLayoutPanel1.Controls.Add(uiLabel1, 0, 1);
tableLayoutPanel1.Controls.Add(cmbProducts, 1, 1);
tableLayoutPanel1.Dock = DockStyle.Fill;
tableLayoutPanel1.Location = new Point(0, 0);
tableLayoutPanel1.Name = "tableLayoutPanel1";
tableLayoutPanel1.RowCount = 3;
tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 6.69856453F));
tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 9.330144F));
tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 83.97129F));
tableLayoutPanel1.Size = new Size(423, 418);
tableLayoutPanel1.TabIndex = 2;
//
// uiLabel1
//
uiLabel1.Dock = DockStyle.Fill;
uiLabel1.Font = new Font("宋体", 12F, FontStyle.Regular, GraphicsUnit.Point, 134);
uiLabel1.ForeColor = Color.FromArgb(48, 48, 48);
uiLabel1.Location = new Point(3, 28);
uiLabel1.Name = "uiLabel1";
uiLabel1.Size = new Size(93, 39);
uiLabel1.TabIndex = 2;
uiLabel1.Text = "当前产品";
uiLabel1.TextAlign = ContentAlignment.MiddleCenter;
//
// cmbProducts
//
cmbProducts.DataSource = null;
cmbProducts.Dock = DockStyle.Left;
cmbProducts.FillColor = Color.White;
cmbProducts.Font = new Font("宋体", 12F, FontStyle.Regular, GraphicsUnit.Point, 134);
cmbProducts.ItemHoverColor = Color.FromArgb(155, 200, 255);
cmbProducts.ItemSelectForeColor = Color.FromArgb(235, 243, 255);
cmbProducts.Location = new Point(103, 33);
cmbProducts.Margin = new Padding(4, 5, 4, 5);
cmbProducts.MinimumSize = new Size(63, 0);
cmbProducts.Name = "cmbProducts";
cmbProducts.Padding = new Padding(0, 0, 30, 2);
cmbProducts.Size = new Size(225, 29);
cmbProducts.SymbolSize = 24;
cmbProducts.TabIndex = 3;
cmbProducts.TextAlignment = ContentAlignment.MiddleLeft;
cmbProducts.Watermark = "";
cmbProducts.SelectedIndexChanged += cmbProducts_SelectedIndexChanged;
//
// toolBtnDeleteProduct
//
toolBtnDeleteProduct.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnDeleteProduct.Image = (Image)resources.GetObject("toolBtnDeleteProduct.Image");
toolBtnDeleteProduct.ImageTransparentColor = Color.Magenta;
toolBtnDeleteProduct.Name = "toolBtnDeleteProduct";
toolBtnDeleteProduct.Size = new Size(60, 25);
toolBtnDeleteProduct.Text = "删除产品";
toolBtnDeleteProduct.Click += toolBtnDeleteProduct_Click;
//
// FrmConfig
//
AutoScaleDimensions = new SizeF(7F, 17F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(423, 418);
Controls.Add(tableLayoutPanel1);
Name = "FrmConfig";
Text = "属性配置";
toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout();
tableLayoutPanel1.ResumeLayout(false);
tableLayoutPanel1.PerformLayout();
ResumeLayout(false);
}
#endregion
private PropertyGrid propertyGrid1;
private ToolStrip toolStrip1;
private ToolStripButton toolBtnApply;
private TableLayoutPanel tableLayoutPanel1;
private ToolStripButton toolBtnAddProduct;
private Sunny.UI.UILabel uiLabel1;
private Sunny.UI.UIComboBox cmbProducts;
private ToolStripButton toolBtnDeleteProduct;
}
}

163
Check.Main/UI/FrmConfig.cs Normal file
View File

@@ -0,0 +1,163 @@
using Check.Main.Camera;
using Check.Main.Common;
using Check.Main.Dispatch;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Xml.Serialization;
using WeifenLuo.WinFormsUI.Docking;
namespace Check.Main.UI
{
public partial class FrmConfig : DockContent
{
//private ProcessConfig _mainSettings=new ProcessConfig();
//private readonly string _configFilePath = Path.Combine(Application.StartupPath, "main_config.xml");
public FrmConfig()
{
InitializeComponent();
ProductManager.OnProductChanged += UpdateUIForNewProduct;
// 初始化UI
InitializeProductComboBox();
//LoadSettings(); // 窗体加载时,读取主配置文件
propertyGrid1.SelectedObject = ProductManager.CurrentConfig; //_mainSettings; // 将配置对象绑定到属性网格
propertyGrid1.PropertyValueChanged += (s, e) => { ProductManager.SaveCurrentProductConfig(); }; // 任何属性改变后自动保存
}
// 【新增】初始化产品下拉列表
private void InitializeProductComboBox()
{
cmbProducts.DataSource = null; // 先清空数据源
cmbProducts.DataSource = ProductManager.ProductList;
cmbProducts.SelectedItem = ProductManager.CurrentProductName;
}
// 【新增】当产品管理器中的产品切换后此方法被调用以更新整个UI
private void UpdateUIForNewProduct()
{
// 使用Invoke确保线程安全
if (this.InvokeRequired)
{
this.Invoke(new Action(UpdateUIForNewProduct));
return;
}
ThreadSafeLogger.Log($"UI正在更新以显示产品 '{ProductManager.CurrentProductName}' 的配置。");
// 更新下拉列表的显示(如果产品列表也变了)
InitializeProductComboBox();
// 【关键】将PropertyGrid重新绑定到新产品的配置对象上
propertyGrid1.SelectedObject = ProductManager.CurrentConfig;
propertyGrid1.Refresh(); // 强制刷新UI
}
private void toolBtnApply_Click(object sender, EventArgs e)
{
ThreadSafeLogger.Log("用户点击“应用”按钮...");
// 1. 确保在应用前,任何可能未触发 PropertyValueChanged 的更改都被保存。
ConfigurationManager.SaveChanges();
// 2. 获取主窗体引用
var mainForm = this.DockPanel.FindForm() as FrmMain;
mainForm?.ClearStatusStrip(); // 清理UI状态
// 3. 从 ConfigurationManager 获取最新的相机配置列表
var cameraSettings = ConfigurationManager.GetCurrentConfig();//.CameraSettings;
if (cameraSettings != null)
{
// 使用全局配置来初始化或重新初始化相机。
// CameraManager.Initialize 内部会首先调用 Shutdown所以这是个完整的重启流程。
CameraManager.Initialize(cameraSettings, mainForm);
}
ThreadSafeLogger.Log("中央配置已成功应用到相机系统。");
//MessageBox.Show("主配置已应用!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void cmbProducts_SelectedIndexChanged(object sender, EventArgs e)
{
if (cmbProducts.SelectedItem is string selectedProduct && selectedProduct != ProductManager.CurrentProductName)
{
ProductManager.SwitchToProduct(selectedProduct);
}
}
private void toolBtnAddProduct_Click(object sender, EventArgs e)
{
// 使用一个简单的输入框来获取新产品名称
string newName = ShowInputDialog("请输入新产品名称:");
if (!string.IsNullOrWhiteSpace(newName))
{
if (ProductManager.AddNewProduct(newName))
{
ThreadSafeLogger.Log($"成功添加新产品: {newName}");
}
else
{
MessageBox.Show("产品名称无效或已存在!", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
// 一个简单的输入对话框辅助方法
public static string ShowInputDialog(string text)
{
Form prompt = new Form()
{
Width = 300,
Height = 150,
FormBorderStyle = FormBorderStyle.FixedDialog,
Text = text,
StartPosition = FormStartPosition.CenterScreen
};
Label textLabel = new Label() { Left = 50, Top = 20, Text = "产品名称:" };
TextBox textBox = new TextBox() { Left = 50, Top = 50, Width = 200 };
Button confirmation = new Button() { Text = "确定", Left = 150, Width = 100, Top = 80, DialogResult = DialogResult.OK };
confirmation.Click += (sender, e) => { prompt.Close(); };
prompt.Controls.Add(textBox);
prompt.Controls.Add(confirmation);
prompt.Controls.Add(textLabel);
prompt.AcceptButton = confirmation;
return prompt.ShowDialog() == DialogResult.OK ? textBox.Text : "";
}
private void toolBtnDeleteProduct_Click(object sender, EventArgs e)
{
// 1. 获取当前选中的产品
string productToDelete = ProductManager.CurrentProductName;
if (string.IsNullOrWhiteSpace(productToDelete) || productToDelete == "DefaultProduct")
{
MessageBox.Show("不能删除默认产品或无效产品!", "操作无效", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
// 2. 弹出安全确认对话框
var confirmResult = MessageBox.Show($"您确定要永久删除产品 '{productToDelete}' 吗?\n\n此操作不可恢复将删除其所有相关配置",
"确认删除",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning);
if (confirmResult == DialogResult.Yes)
{
// 3. 调用核心删除逻辑
if (ProductManager.DeleteProduct(productToDelete))
{
ThreadSafeLogger.Log($"用户成功删除了产品 '{productToDelete}'。");
// 无需在这里手动更新UI因为DeleteProduct方法内部会触发 OnProductChanged 事件,
// 而我们的 UpdateUIForNewProduct 方法会自动响应这个事件并刷新整个界面。
}
else
{
MessageBox.Show($"删除产品 '{productToDelete}' 失败,请查看日志获取详细信息。", "删除失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
}
}

View File

@@ -0,0 +1,151 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="toolBtnApply.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACRSURBVDhPY/j27dt/SjDYACcnJ7IwigEf3n8kCZNswPNb
J/+f6DYF0yA+yQac6Db5f6hWCmwIiE+mC0wIu2DS2Vf/F1x6DefjwlgNyNr34r/0wkdgTMgQDAOQNRNj
CIoBOg0rMTTDMLIhIHbriZeYBmDTiIxBGkEYxge5liQDsGGQqykyAISpZwAlmIEywMAAAAc1/Jwvt6sN
AAAAAElFTkSuQmCC
</value>
</data>
<data name="toolBtnAddProduct.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACRSURBVDhPY/j27dt/SjDYACcnJ7IwigEf3n8kCZNswPNb
J/+f6DYF0yA+yQac6Db5f6hWCmwIiE+mC0wIu2DS2Vf/F1x6DefjwlgNyNr34r/0wkdgTMgQDAOQNRNj
CIoBOg0rMTTDMLIhIHbriZeYBmDTiIxBGkEYxge5liQDsGGQqykyAISpZwAlmIEywMAAAAc1/Jwvt6sN
AAAAAElFTkSuQmCC
</value>
</data>
<data name="toolBtnDeleteProduct.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACRSURBVDhPY/j27dt/SjDYACcnJ7IwigEf3n8kCZNswPNb
J/+f6DYF0yA+yQac6Db5f6hWCmwIiE+mC0wIu2DS2Vf/F1x6DefjwlgNyNr34r/0wkdgTMgQDAOQNRNj
CIoBOg0rMTTDMLIhIHbriZeYBmDTiIxBGkEYxge5liQDsGGQqykyAISpZwAlmIEywMAAAAc1/Jwvt6sN
AAAAAElFTkSuQmCC
</value>
</data>
</root>

66
Check.Main/UI/FrmLog.Designer.cs generated Normal file
View File

@@ -0,0 +1,66 @@
namespace Check.Main.UI
{
partial class FrmLog
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.txtLog = new Sunny.UI.UIRichTextBox();
this.SuspendLayout();
//
// txtLog
//
this.txtLog.Dock = System.Windows.Forms.DockStyle.Fill;
this.txtLog.FillColor = System.Drawing.Color.White;
this.txtLog.Font = new System.Drawing.Font("宋体", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134)));
this.txtLog.Location = new System.Drawing.Point(0, 0);
this.txtLog.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5);
this.txtLog.MinimumSize = new System.Drawing.Size(1, 1);
this.txtLog.Name = "txtLog";
this.txtLog.Padding = new System.Windows.Forms.Padding(2);
this.txtLog.ReadOnly = true;
this.txtLog.ShowText = false;
this.txtLog.Size = new System.Drawing.Size(399, 299);
this.txtLog.TabIndex = 0;
this.txtLog.TextAlignment = System.Drawing.ContentAlignment.MiddleLeft;
//
// FrmLog
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(399, 299);
this.Controls.Add(this.txtLog);
this.Name = "FrmLog";
this.Text = "FrmLog";
this.ResumeLayout(false);
}
#endregion
private Sunny.UI.UIRichTextBox txtLog;
}
}

35
Check.Main/UI/FrmLog.cs Normal file
View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using WeifenLuo.WinFormsUI.Docking;
namespace Check.Main.UI
{
public partial class FrmLog : DockContent
{
public FrmLog()
{
InitializeComponent();
}
public void AddLog(string message)
{
if (txtLog.InvokeRequired)
{
txtLog.BeginInvoke(new Action(() => AddLog(message)));
return;
}
if (txtLog.Lines.Length > 500)
{
txtLog.Clear();
}
txtLog.AppendText(message + Environment.NewLine);
txtLog.ScrollToCaret();
}
}
}

120
Check.Main/UI/FrmLog.resx Normal file
View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

145
Check.Main/UI/ModelListEditor.Designer.cs generated Normal file
View File

@@ -0,0 +1,145 @@
namespace Check.Main.UI
{
partial class ModelListEditor
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(ModelListEditor));
propertyGrid1 = new PropertyGrid();
listBoxModels = new ListBox();
toolStrip1 = new ToolStrip();
toolBtnAdd = new ToolStripButton();
toolBtnRemove = new ToolStripButton();
toolBtnSet = new ToolStripButton();
tableLayoutPanel1 = new TableLayoutPanel();
toolStrip1.SuspendLayout();
tableLayoutPanel1.SuspendLayout();
SuspendLayout();
//
// propertyGrid1
//
propertyGrid1.Dock = DockStyle.Fill;
propertyGrid1.Location = new Point(245, 4);
propertyGrid1.Margin = new Padding(4);
propertyGrid1.Name = "propertyGrid1";
propertyGrid1.Size = new Size(316, 400);
propertyGrid1.TabIndex = 1;
//
// listBoxModels
//
listBoxModels.Dock = DockStyle.Fill;
listBoxModels.FormattingEnabled = true;
listBoxModels.ItemHeight = 17;
listBoxModels.Location = new Point(4, 4);
listBoxModels.Margin = new Padding(4);
listBoxModels.Name = "listBoxModels";
listBoxModels.Size = new Size(233, 400);
listBoxModels.TabIndex = 2;
listBoxModels.SelectedIndexChanged += listBoxModels_SelectedIndexChanged;
//
// toolStrip1
//
toolStrip1.Items.AddRange(new ToolStripItem[] { toolBtnAdd, toolBtnRemove, toolBtnSet });
toolStrip1.Location = new Point(0, 0);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new Size(565, 25);
toolStrip1.TabIndex = 3;
toolStrip1.Text = "toolStrip1";
//
// toolBtnAdd
//
toolBtnAdd.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnAdd.Image = (Image)resources.GetObject("toolBtnAdd.Image");
toolBtnAdd.ImageTransparentColor = Color.Magenta;
toolBtnAdd.Name = "toolBtnAdd";
toolBtnAdd.Size = new Size(36, 22);
toolBtnAdd.Text = "添加";
toolBtnAdd.Click += toolBtnAdd_Click;
//
// toolBtnRemove
//
toolBtnRemove.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnRemove.Image = (Image)resources.GetObject("toolBtnRemove.Image");
toolBtnRemove.ImageTransparentColor = Color.Magenta;
toolBtnRemove.Name = "toolBtnRemove";
toolBtnRemove.Size = new Size(36, 22);
toolBtnRemove.Text = "移除";
toolBtnRemove.Click += toolBtnRemove_Click;
//
// toolBtnSet
//
toolBtnSet.DisplayStyle = ToolStripItemDisplayStyle.Text;
toolBtnSet.Image = (Image)resources.GetObject("toolBtnSet.Image");
toolBtnSet.ImageTransparentColor = Color.Magenta;
toolBtnSet.Name = "toolBtnSet";
toolBtnSet.Size = new Size(60, 22);
toolBtnSet.Text = "应用配置";
toolBtnSet.Click += toolBtnSet_Click;
//
// tableLayoutPanel1
//
tableLayoutPanel1.ColumnCount = 2;
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 42.65487F));
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 57.34513F));
tableLayoutPanel1.Controls.Add(listBoxModels, 0, 0);
tableLayoutPanel1.Controls.Add(propertyGrid1, 1, 0);
tableLayoutPanel1.Dock = DockStyle.Fill;
tableLayoutPanel1.Location = new Point(0, 25);
tableLayoutPanel1.Name = "tableLayoutPanel1";
tableLayoutPanel1.RowCount = 1;
tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 50F));
tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 50F));
tableLayoutPanel1.Size = new Size(565, 408);
tableLayoutPanel1.TabIndex = 4;
//
// ModelListEditor
//
AutoScaleDimensions = new SizeF(7F, 17F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(565, 433);
Controls.Add(tableLayoutPanel1);
Controls.Add(toolStrip1);
Name = "ModelListEditor";
Text = "模型配置";
toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout();
tableLayoutPanel1.ResumeLayout(false);
ResumeLayout(false);
PerformLayout();
}
#endregion
private PropertyGrid propertyGrid1;
private ListBox listBoxModels;
private ToolStrip toolStrip1;
private ToolStripButton toolBtnAdd;
private ToolStripButton toolBtnRemove;
private ToolStripButton toolBtnSet;
private TableLayoutPanel tableLayoutPanel1;
}
}

View File

@@ -0,0 +1,175 @@
using Check.Main.Camera;
using Check.Main.Infer;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Xml.Serialization;
using WeifenLuo.WinFormsUI.Docking;
namespace Check.Main.UI
{
public partial class ModelListEditor : DockContent
{
public List<ModelSettings> _settingsList { get; private set; }
private readonly string _configFilePath = Path.Combine(Application.StartupPath, "model.xml");
public ModelListEditor()
{
InitializeComponent();
_settingsList = new List<ModelSettings>();
LoadSettings();
RefreshListBox();
AdaptForDialogMode(); // 调整UI为对话框模式
}
/// <summary>
/// 用于从外部接收一个设置列表进行编辑。
/// </summary>
/// <param name="settingsToEdit">要编辑的相机设置列表</param>
public ModelListEditor(List<ModelSettings> settingsToEdit)
{
InitializeComponent();
// 创建一个现有列表的副本进行编辑,这样如果用户点“取消”,原始列表不会被影响
_settingsList = settingsToEdit != null
? settingsToEdit.Select(s => s.Clone() as ModelSettings).ToList() // 深度克隆更好,这里为简化用浅克隆
: new List<ModelSettings>();
// 别忘了为列表中的每个对象重新订阅事件
foreach (var setting in _settingsList)
{
setting.PropertyChanged += Setting_PropertyChanged;
}
RefreshListBox();
AdaptForDialogMode(); // 调整UI为对话框模式
}
/// <summary>
/// 【新增的方法】
/// 调整UI使其更像一个对话框。
/// </summary>
private void AdaptForDialogMode()
{
// 将“应用配置”按钮改为“确定”
toolBtnSet.Text = "确定";
toolBtnSet.ToolTipText = "保存更改并关闭窗口";
// 新增一个“取消”按钮
var toolBtnCancel = new ToolStripButton("取消")
{
DisplayStyle = ToolStripItemDisplayStyle.Text,
Alignment = ToolStripItemAlignment.Right // 靠右对齐
};
toolBtnCancel.Click += (sender, e) =>
{
this.DialogResult = DialogResult.Cancel;
this.Close();
};
toolStrip1.Items.Add(toolBtnCancel);
}
/// <summary>
/// 【新增的事件处理方法】
/// 当PropertyGrid中的属性值被用户修改后触发此事件。
/// </summary>
// 刷新左侧的相机列表
private void RefreshListBox()
{
listBoxModels.Items.Clear();
foreach (var setting in _settingsList)
{
listBoxModels.Items.Add(setting.Name);
}
}
/// <summary>
/// 【全新的事件处理方法】
/// 监听单个CameraSettings对象的属性变更通知。
/// </summary>
private void Setting_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
// 检查是不是“Name”属性发生了变化
if (e.PropertyName == nameof(CameraSettings.Name))
{
// 'sender' 就是那个属性发生了变化的 CameraSettings 对象
var changedSetting = sender as ModelSettings;
if (changedSetting == null) return;
// 在 _settingsList 中找到这个对象的索引
int index = _settingsList.IndexOf(changedSetting);
// 如果找到了就更新ListBox中对应项的显示文本
if (index >= 0)
{
// 使用Invoke确保线程安全虽然在此场景下通常不是问题但这是个好习惯
this.Invoke(new Action(() =>
{
listBoxModels.Items[index] = changedSetting.Name;
}));
}
}
}
private void listBoxModels_SelectedIndexChanged(object sender, EventArgs e)
{
if (listBoxModels.SelectedIndex >= 0)
{
propertyGrid1.SelectedObject = _settingsList[listBoxModels.SelectedIndex];
}
else
{
propertyGrid1.SelectedObject = null;
}
}
private void toolBtnAdd_Click(object sender, EventArgs e)
{
var newSetting = new ModelSettings { Name = $"model-{_settingsList.Count + 1}" };
_settingsList.Add(newSetting);
newSetting.PropertyChanged += Setting_PropertyChanged;
RefreshListBox();
listBoxModels.SelectedIndex = listBoxModels.Items.Count - 1;
}
private void toolBtnRemove_Click(object sender, EventArgs e)
{
if (listBoxModels.SelectedIndex >= 0)
{
_settingsList.RemoveAt(listBoxModels.SelectedIndex);
propertyGrid1.SelectedObject = null;
RefreshListBox();
}
}
private void toolBtnSet_Click(object sender, EventArgs e)
{
this.DialogResult = DialogResult.OK;
this.Close();
}
// 加载XML配置文件
private void LoadSettings()
{
if (!File.Exists(_configFilePath)) return;
try
{
XmlSerializer serializer = new XmlSerializer(typeof(List<ModelSettings>));
using (FileStream fs = new FileStream(_configFilePath, FileMode.Open))
{
_settingsList = (List<ModelSettings>)serializer.Deserialize(fs);
}
}
catch (Exception ex)
{
MessageBox.Show("加载相机配置失败: " + ex.Message);
}
foreach (var setting in _settingsList)
{
setting.PropertyChanged += Setting_PropertyChanged;
}
}
}
}

View File

@@ -0,0 +1,172 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="toolBtnAdd.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIDSURBVDhPpZLrS5NhGMb3j4SWh0oRQVExD4gonkDpg4hG
YKxG6WBogkMZKgPNCEVJFBGdGETEvgwyO9DJE5syZw3PIlPEE9pgBCLZ5XvdMB8Ew8gXbl54nuf63dd9
0OGSnwCahxbPRNPAPMw9Xpg6ZmF46kZZ0xSKzJPIrhpDWsVnpBhGkKx3nAX8Pv7z1zg8OoY/cITdn4fw
bf/C0kYAN3Ma/w3gWfZL5kzTKBxjWyK2DftwI9tyMYCZKXbNHaD91bLYJrDXsYbrWfUKwJrPE9M2M1Oc
VzOOpHI7Jr376Hi9ogHqFIANO0/MmmmbmSmm9a8ze+I4MrNWAdjtoJgWcx+PSzg166yZZ8xM8XvXDix9
c4jIqFYAjoriBV9AhEPv1mH/sonogha0afbZMMZz+yreTGyhpusHwtNNCsA5U1zS4BLxzJIfg299qO32
Ir7UJtZfftyATqeT+8o2D8JSjQrAJblrncYL7ZJ2+bfaFnC/1S1NjL3diRat7qrO7wLRP3HjWsojBeCo
mDEo5mNjuweFGvjWg2EBhCbpkW78htSHHwRyNdmgAFzPEee2iFkzayy2OLXzT4gr6UdUnlXrullsxxQ+
kx0g8BTA3aZlButjSTyjODq/WcQcW/B/Je4OQhLvKQDnzN1mp0nnkvAhR8VuMzNrpm1mpjgkoVwB/v8D
TgDQASA1MVpwzwAAAABJRU5ErkJggg==
</value>
</data>
<data name="toolBtnRemove.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIDSURBVDhPpZLrS5NhGMb3j4SWh0oRQVExD4gonkDpg4hG
YKxG6WBogkMZKgPNCEVJFBGdGETEvgwyO9DJE5syZw3PIlPEE9pgBCLZ5XvdMB8Ew8gXbl54nuf63dd9
0OGSnwCahxbPRNPAPMw9Xpg6ZmF46kZZ0xSKzJPIrhpDWsVnpBhGkKx3nAX8Pv7z1zg8OoY/cITdn4fw
bf/C0kYAN3Ma/w3gWfZL5kzTKBxjWyK2DftwI9tyMYCZKXbNHaD91bLYJrDXsYbrWfUKwJrPE9M2M1Oc
VzOOpHI7Jr376Hi9ogHqFIANO0/MmmmbmSmm9a8ze+I4MrNWAdjtoJgWcx+PSzg166yZZ8xM8XvXDix9
c4jIqFYAjoriBV9AhEPv1mH/sonogha0afbZMMZz+yreTGyhpusHwtNNCsA5U1zS4BLxzJIfg299qO32
Ir7UJtZfftyATqeT+8o2D8JSjQrAJblrncYL7ZJ2+bfaFnC/1S1NjL3diRat7qrO7wLRP3HjWsojBeCo
mDEo5mNjuweFGvjWg2EBhCbpkW78htSHHwRyNdmgAFzPEee2iFkzayy2OLXzT4gr6UdUnlXrullsxxQ+
kx0g8BTA3aZlButjSTyjODq/WcQcW/B/Je4OQhLvKQDnzN1mp0nnkvAhR8VuMzNrpm1mpjgkoVwB/v8D
TgDQASA1MVpwzwAAAABJRU5ErkJggg==
</value>
</data>
<data name="toolBtnSet.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIDSURBVDhPpZLrS5NhGMb3j4SWh0oRQVExD4gonkDpg4hG
YKxG6WBogkMZKgPNCEVJFBGdGETEvgwyO9DJE5syZw3PIlPEE9pgBCLZ5XvdMB8Ew8gXbl54nuf63dd9
0OGSnwCahxbPRNPAPMw9Xpg6ZmF46kZZ0xSKzJPIrhpDWsVnpBhGkKx3nAX8Pv7z1zg8OoY/cITdn4fw
bf/C0kYAN3Ma/w3gWfZL5kzTKBxjWyK2DftwI9tyMYCZKXbNHaD91bLYJrDXsYbrWfUKwJrPE9M2M1Oc
VzOOpHI7Jr376Hi9ogHqFIANO0/MmmmbmSmm9a8ze+I4MrNWAdjtoJgWcx+PSzg166yZZ8xM8XvXDix9
c4jIqFYAjoriBV9AhEPv1mH/sonogha0afbZMMZz+yreTGyhpusHwtNNCsA5U1zS4BLxzJIfg299qO32
Ir7UJtZfftyATqeT+8o2D8JSjQrAJblrncYL7ZJ2+bfaFnC/1S1NjL3diRat7qrO7wLRP3HjWsojBeCo
mDEo5mNjuweFGvjWg2EBhCbpkW78htSHHwRyNdmgAFzPEee2iFkzayy2OLXzT4gr6UdUnlXrullsxxQ+
kx0g8BTA3aZlButjSTyjODq/WcQcW/B/Je4OQhLvKQDnzN1mp0nnkvAhR8VuMzNrpm1mpjgkoVwB/v8D
TgDQASA1MVpwzwAAAABJRU5ErkJggg==
</value>
</data>
<metadata name="$this.TrayHeight" type="System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>37</value>
</metadata>
</root>

View File

@@ -0,0 +1,381 @@
using OpenCvSharp;
using OpenCvSharp.Extensions;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Check.Main.UI
{
public class ZoomPictureBox : PictureBox, IDisposable
{
private float zoom = 1.0f;
private System.Drawing.Point imageLocation;
private Image image;
private bool dragging = false;
private System.Drawing.Point mouseDownPos;
private System.Drawing.Point imageLocationOnMouseDown;
// 矩形绘制相关
private bool isDrawingRect = false; // 绘制状态
private Rectangle currentRect; // 当前绘制矩形
private Rectangle storedRect; // 上次绘制并固定的矩形
private bool hasStoredRect = false; // 是否存在固定矩形
private System.Drawing.Point rectStartPoint;
// 一个私有的锁对象,专门用于保护对 'image' 字段的访问。
private readonly object imageLock = new object();
private bool croppingEnabled = true;
//private bool croppingEnabled = true;
[Category("Behavior")]
[Description("是否允许通过右键绘制矩形裁剪区域。设置为 false 则不再响应右键并清除已有矩形。")]
public bool CroppingEnabled
{
get => croppingEnabled;
set
{
croppingEnabled = value;
// 禁用裁剪时清除所有矩形和裁剪结果
if (!croppingEnabled)
{
isDrawingRect = false;
hasStoredRect = false;
LastCroppedMat?.Dispose();
LastCroppedMat = null;
}
Invalidate();
}
}
// 裁剪完成事件,外部可订阅以接收 Mat
public event Action<Mat> Cropped;
public Mat LastCroppedMat { get; private set; }
private Color backgroundFillColor = Color.LightSteelBlue;
private Color rectangleColor = Color.Red;
private int rectangleThickness = 2;
[Category("Appearance")]
[Description("指定控件背景的填充颜色。使用 BackColor 属性同步更新。")]
public Color BackgroundFillColor
{
get => backgroundFillColor;
set
{
backgroundFillColor = value;
base.BackColor = value; // 同步 WinForms BackColor
Invalidate();
}
}
[Category("Appearance")]
[Description("绘制矩形框的颜色。")]
public Color RectangleColor { get => rectangleColor; set { rectangleColor = value; Invalidate(); } }
[Category("Appearance")]
[Description("绘制矩形框的线宽。")]
public int RectangleThickness { get => rectangleThickness; set { rectangleThickness = Math.Max(1, value); Invalidate(); } }
public ZoomPictureBox()
{
DoubleBuffered = true;
ResizeRedraw = true;
base.BackColor = backgroundFillColor;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);
MouseWheel += ZoomPictureBox_MouseWheel;
MouseDown += ZoomPictureBox_MouseDown;
MouseMove += ZoomPictureBox_MouseMove;
MouseUp += ZoomPictureBox_MouseUp;
DoubleClick += ZoomPictureBox_DoubleClick;
}
// 重写背景绘制,使用自定义背景色
protected override void OnPaintBackground(PaintEventArgs e)
{
e.Graphics.Clear(backgroundFillColor);
}
//[Browsable(false)]
//public new Image Image
//{
// get => image;
// set
// {
// image = value;
// zoom = 1.0f;
// LastCroppedMat?.Dispose();
// LastCroppedMat = null;
// isDrawingRect = false;
// hasStoredRect = false;
// FitImage();
// Invalidate();
// }
//}
// 【关键优化】
// 2. 重写 Control.Dispose 方法来释放我们自己的资源
protected override void Dispose(bool disposing)
{
if (disposing)
{
// 在这里释放托管和非托管资源
// 清理事件订阅 (虽然在这个类里没有,但这是个好习惯)
// 释放我们持有的最后一个图像资源
lock (imageLock)
{
this.image?.Dispose();
this.image = null;
}
// 释放我们持有的最后一个裁剪结果资源
this.LastCroppedMat?.Dispose();
this.LastCroppedMat = null;
}
// 调用基类的 Dispose 方法来完成标准清理
base.Dispose(disposing);
}
// 重写 Image 属性,但不做任何额外操作,因为我们将通过一个新方法来更新它。
[Browsable(false)]
public new Image Image
{
get
{
// 在访问时也加锁,确保读取的是一个完整的对象
lock (imageLock)
{
return image;
}
}
// set 访问器可以保持原样,但我们不再直接使用它来更新图像
private set
{
lock (imageLock)
{
image = value;
}
}
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
//在绘制前获取锁确保在绘制期间image对象不会被其他线程替换或释放
lock (imageLock)
{
if (image != null)
{
try
{
int w = (int)(image.Width * zoom);
int h = (int)(image.Height * zoom);
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.DrawImage(image, new Rectangle(imageLocation, new System.Drawing.Size(w, h)));
}
catch (Exception)
{
// 如果在绘制时仍然发生罕见的GDI+错误,静默忽略,避免程序崩溃。
// 这通常意味着图像状态仍然存在问题但UI不会因此卡死。
}
}
}
//if (image != null)
//{
// int w = (int)(image.Width * zoom);
// int h = (int)(image.Height * zoom);
// g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
// g.DrawImage(image, new Rectangle(imageLocation, new System.Drawing.Size(w, h)));
//}
// 仅当允许裁剪时才绘制矩形
if (croppingEnabled)
{
using (var pen = new Pen(rectangleColor, rectangleThickness))
{
if (hasStoredRect) g.DrawRectangle(pen, storedRect);
else if (isDrawingRect) g.DrawRectangle(pen, currentRect);
}
}
}
/// <summary>
/// 一个线程安全的、用于更新显示图像的公共方法。
/// 这个方法将替换掉直接的 pictureBox.Image = ... 赋值。
/// </summary>
/// <param name="newImage">要显示的新图像。此方法会接管该对象的所有权。</param>
public void SetImageThreadSafe(Image newImage)
{
Image oldImage = null;
lock (imageLock)
{
// 1. 保存旧图像的引用,以便在锁外部释放它
oldImage = this.image;
// 2. 将新图像赋值给成员字段
this.image = newImage;
}
// 3. 【关键】在锁的外部释放旧图像,避免长时间持有锁
oldImage?.Dispose();
// 4. 计算自适应并触发重绘
FitImage();
Invalidate();
}
private void FitImage()
{
//if (image == null) return;
//float sx = (float)ClientSize.Width / image.Width;
//float sy = (float)ClientSize.Height / image.Height;
//zoom = Math.Min(sx, sy);
//CenterImage();
//在访问image属性前获取锁
lock (imageLock)
{
if (image == null) return;
try
{
float sx = (float)ClientSize.Width / image.Width;
float sy = (float)ClientSize.Height / image.Height;
zoom = Math.Min(sx, sy);
CenterImage();
}
catch (Exception)
{
// 忽略错误,同 OnPaint
}
}
}
private void CenterImage()
{
if (image == null) return;
int w = (int)(image.Width * zoom);
int h = (int)(image.Height * zoom);
imageLocation = new System.Drawing.Point((ClientSize.Width - w) / 2, (ClientSize.Height - h) / 2);
}
private void ZoomPictureBox_MouseWheel(object sender, MouseEventArgs e)
{
if (image == null) return;
float oldZoom = zoom;
zoom *= e.Delta > 0 ? 1.1f : 1 / 1.1f;
zoom = Math.Max(0.1f, Math.Min(zoom, 100f));
var m = e.Location;
float ix = (m.X - imageLocation.X) / oldZoom;
float iy = (m.Y - imageLocation.Y) / oldZoom;
imageLocation = new System.Drawing.Point((int)(m.X - ix * zoom), (int)(m.Y - iy * zoom));
Invalidate();
}
private void ZoomPictureBox_MouseDown(object sender, MouseEventArgs e)
{
if (image == null) return;
if (e.Button == MouseButtons.Left)
{
hasStoredRect = false;
LastCroppedMat?.Dispose();
LastCroppedMat = null;
dragging = true;
mouseDownPos = e.Location;
imageLocationOnMouseDown = imageLocation;
Cursor = Cursors.Hand;
}
else if (e.Button == MouseButtons.Right && croppingEnabled)
{
// 清除上次固定矩形和裁剪
hasStoredRect = false;
LastCroppedMat?.Dispose();
LastCroppedMat = null;
// 开始新绘制
isDrawingRect = true;
rectStartPoint = e.Location;
currentRect = new Rectangle(e.Location, System.Drawing.Size.Empty);
Invalidate();
}
}
private void ZoomPictureBox_MouseMove(object sender, MouseEventArgs e)
{
if (dragging)
{
var delta = new System.Drawing.Point(e.X - mouseDownPos.X, e.Y - mouseDownPos.Y);
imageLocation = new System.Drawing.Point(imageLocationOnMouseDown.X + delta.X, imageLocationOnMouseDown.Y + delta.Y);
Invalidate();
}
else if (isDrawingRect)
{
int x = Math.Min(rectStartPoint.X, e.X);
int y = Math.Min(rectStartPoint.Y, e.Y);
int w = Math.Abs(e.X - rectStartPoint.X);
int h = Math.Abs(e.Y - rectStartPoint.Y);
currentRect = new Rectangle(x, y, w, h);
Invalidate();
}
}
private void ZoomPictureBox_MouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left && dragging)
{
dragging = false;
Cursor = Cursors.Default;
}
else if (e.Button == MouseButtons.Right && isDrawingRect)
{
isDrawingRect = false;
// 固定当前矩形框
storedRect = currentRect;
hasStoredRect = true;
if (croppingEnabled && image is Bitmap bmp)
{
this.LastCroppedMat?.Dispose();
this.LastCroppedMat = null;
Mat srcMat = null;
try
{
int ix = (int)((storedRect.X - imageLocation.X) / zoom);
int iy = (int)((storedRect.Y - imageLocation.Y) / zoom);
int iw = (int)(storedRect.Width / zoom);
int ih = (int)(storedRect.Height / zoom);
ix = Math.Max(0, ix);
iy = Math.Max(0, iy);
if (ix + iw > bmp.Width) iw = bmp.Width - ix;
if (iy + ih > bmp.Height) ih = bmp.Height - iy;
if (iw > 0 && ih > 0)
{
srcMat = BitmapConverter.ToMat(bmp);
var roi = new Rect(ix, iy, iw, ih);
LastCroppedMat = new Mat(srcMat, roi);
Cropped?.Invoke(LastCroppedMat);
bmp.Dispose();
}
}
finally
{
// 5.确保从Bitmap转换来的 srcMat 被释放
srcMat?.Dispose();
}
}
Invalidate();
}
}
private void ZoomPictureBox_DoubleClick(object sender, EventArgs e)
{
if (image == null) return;
FitImage();
Invalidate();
}
}
}

25
CheckDevice.sln Normal file
View File

@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36310.24 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Check.Main", "Check.Main\Check.Main.csproj", "{3AEFC44E-BD2D-48A9-8C0D-BF4D429E1166}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3AEFC44E-BD2D-48A9-8C0D-BF4D429E1166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3AEFC44E-BD2D-48A9-8C0D-BF4D429E1166}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3AEFC44E-BD2D-48A9-8C0D-BF4D429E1166}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3AEFC44E-BD2D-48A9-8C0D-BF4D429E1166}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {53CBAFF8-60D2-4B61-B76A-2C11D7DA949E}
EndGlobalSection
EndGlobal