28 Commits

Author SHA1 Message Date
LI Wei.lw 0031c2cfad 已合并 PR 37: 点点距和点线距功能合并
1.点点距;
2.点线距;
3.十字辅助线;
2026-04-24 13:09:09 +08:00
ZHANG Zhengxuan 7594a66408 已合并 PR 36: 调整CNC为两列布局
![image.png](http://cntao-ap-v83/HMQ-Solution/7ff128fd-5cc6-4feb-9529-2a03b2895662/_apis/git/repositories/e2c5485f-4369-4ed9-9fb9-d087ca4e04b6/pullRequests/36/attachments/image.png)
1、CNC界面及交互优化,包括插入节点,节点排序,编辑,算子是否启用
2、当流程图异常时,状态提醒
3、优化树形结构
4、CNC数据源考虑
2026-04-24 13:05:02 +08:00
zhengxuan.zhang f64a0f7b31 优化CNC编辑为2列布局 2026-04-24 11:18:44 +08:00
李伟 9e39447144 图像切换时十字辅助线跟随刷新 2026-04-24 11:01:17 +08:00
zhengxuan.zhang dbfa09a9fd 检测模块_0 成连续编号 2026-04-24 10:57:21 +08:00
李伟 ef4e1f9eda 点线距测量工具实现 2026-04-24 10:53:51 +08:00
zhengxuan.zhang f3e77562b1 高亮节点、取消前面的[n]序号、参考位置,保存位置,参数区为只读 2026-04-24 10:44:22 +08:00
李伟 eefbd1d1c8 将十字线以及点点距ROI实现迁移到XP.ImageProcessing.RoiControl中 2026-04-24 09:26:30 +08:00
李伟 7a0731386e 十字辅助线 2026-04-24 08:31:31 +08:00
TANG Haowei 80e3aa9ff6 Merged PR 35: 增加校准通用代码放在XP.Calibration里面
增加校准通用代码:读图片,找圆心,计算双球距离
2026-04-24 08:20:52 +08:00
zhengxuan.zhang 9911566675 增加节点右键菜单功能,支持上方插入和下方插入 2026-04-24 01:58:11 +08:00
Tanghaowei b47c261bc3 增加校准通用代码 2026-04-23 19:38:44 +08:00
zhengxuan.zhang ed4b1d8031 紧凑树形结构布局 2026-04-23 18:14:23 +08:00
zhengxuan.zhang ca86e8f7e8 调整CNC树形结构风格,简化界面 2026-04-23 18:09:53 +08:00
zhengxuan.zhang 2042c6c949 显示CNC文件名,取消节点的上、下移动 2026-04-23 17:50:11 +08:00
zhengxuan.zhang c9c0d93d8a 只有当流程图执行异常时,才显示强调色 2026-04-23 17:33:12 +08:00
zhengxuan.zhang 24e0489cde 显示xmp文件名;取消实时图像底部栏 2026-04-23 17:29:25 +08:00
zhengxuan.zhang 92ece60c01 删除主页面 NavigationPanelView 区域 2026-04-23 17:11:15 +08:00
zhengxuan.zhang 5ae5963353 当因为算子参数输入不合理,或者执行异常,要在状态栏显示 2026-04-23 17:04:41 +08:00
李伟 c2650e737b 图像区域添加右键菜单功能 2026-04-23 16:53:58 +08:00
李伟 d5b421b811 使用像素尺寸,避免DPI不同导致DIP尺寸与实际像素不一致 2026-04-23 16:53:17 +08:00
zhengxuan.zhang 338358a71c 算子调节参数控件的显示与选择的优化 2026-04-23 16:47:13 +08:00
zhengxuan.zhang f816cf4b8b XP 模块流水线 (*.xpm)|*.xpm 2026-04-23 16:28:53 +08:00
zhengxuan.zhang 9bdd67ffb7 删除原有的上 下,删 按钮方式的操作逻辑,改用拖动,和左键激活 2026-04-23 16:24:49 +08:00
李伟 3aa64843c8 去掉图像显示区的放大、缩小、适应按钮;改为鼠标右键弹出选择菜单 2026-04-23 16:16:49 +08:00
zhengxuan.zhang 3f3820073f 配方编辑部分的交互逻辑 2026-04-23 16:12:55 +08:00
李伟 3e337cf04f 点点距测量工具初步实现 2026-04-23 16:03:39 +08:00
zhengxuan.zhang e0326c2d80 修复主界面;增加报告数据源 2026-04-23 07:14:10 +08:00
46 changed files with 4524 additions and 618 deletions
+2 -2
View File
@@ -106,5 +106,5 @@ dotnet build XplorePlane.sln -c Release
- [x] 相机实时影像集成(连接、采集、Live View、像素坐标显示)
- [x] 相机参数设置对话框(曝光、增益、分辨率、像素格式)
- [x] 主界面硬件栏相机设置按钮
- [ ] 打通与硬件层的调用流程
- [ ] 打通与图像层的调用流程
- [x] 打通与硬件层的调用流程
- [x] 打通与图像层的调用流程
+126
View File
@@ -0,0 +1,126 @@
using System.Drawing;
using Emgu.CV;
using Emgu.CV.Util;
using Emgu.CV.Structure;
using XP.Calibration.Core;
using XP.Calibration.Models;
namespace XP.Calibration;
/// <summary>
/// 中心校准: 椭圆拟合 + 透视修正 | Center calibration via ellipse fitting + perspective correction
/// </summary>
public class CenterCalibration
{
/// <summary>
/// 从投影序列执行中心校准
/// </summary>
/// <param name="projections">投影帧列表 (CV_32F)</param>
/// <param name="geo">几何参数</param>
/// <returns>校准结果</returns>
public CenterCalibrationResult Calibrate(List<Mat> projections, GeoParams geo)
{
// 检测各帧球心
var centers = new List<PointF>();
for (int i = 0; i < projections.Count; i++)
{
if (BallDetector.DetectCenter(projections[i], out var c))
centers.Add(c);
}
if (centers.Count <= 10)
throw new InvalidOperationException(
$"有效检测帧数不足: {centers.Count}, 至少需要 10 帧");
// 椭圆拟合
var ellipse = FitCentersEllipse(centers);
// 放大倍率
double M = geo.DSD / geo.DSO;
// 从椭圆参数反算倾斜角和 R
double ratio = ellipse.ShortAxis / ellipse.LongAxis;
ratio = Math.Clamp(ratio, 0.0, 1.0);
double alphaRad = Math.Acos(ratio);
double alphaDeg = alphaRad * 180.0 / Math.PI;
// 长轴 = 2 * R * M / pixelSize → R = longAxis * pixelSize / (2 * M)
double R = ellipse.LongAxis * geo.PixelSize / (2.0 * M);
// 透视修正
double deltaPx = R * R * Math.Sin(2.0 * alphaRad)
/ (2.0 * geo.DSO * geo.DSO)
* geo.DSD / geo.PixelSize;
// 长轴方向角
var rawEllipse = FitEllipseRaw(centers);
double angleDeg = rawEllipse.Angle;
float w = rawEllipse.Size.Width;
float h = rawEllipse.Size.Height;
if (h > w) angleDeg += 90.0f;
double thetaDeg = 90.0 - angleDeg;
double thetaRad = thetaDeg * Math.PI / 180.0;
double deltaU = deltaPx * Math.Cos(thetaRad);
double deltaV = deltaPx * (-Math.Sin(thetaRad));
double u0 = ellipse.Center.X - deltaU;
double v0 = ellipse.Center.Y - deltaV;
return new CenterCalibrationResult
{
Ellipse = ellipse,
AlphaDeg = alphaDeg,
R_mm = R,
DeltaPx = deltaPx,
FocalU = u0,
FocalV = v0,
DetectedCenters = centers
};
}
/// <summary>
/// 从 RAW 文件执行中心校准 (便捷方法)
/// </summary>
public CenterCalibrationResult CalibrateFromRaw(
string rawPath, int width, int height, int count, GeoParams geo)
{
var projections = RawReader.ReadFloat32(rawPath, width, height, count);
try
{
return Calibrate(projections, geo);
}
finally
{
foreach (var p in projections) p.Dispose();
}
}
/// <summary>
/// 对检测到的球心序列做椭圆拟合, 返回长短轴和角度
/// </summary>
private static EllipseResult FitCentersEllipse(List<PointF> pts)
{
var rotRect = FitEllipseRaw(pts);
float a = rotRect.Size.Width;
float b = rotRect.Size.Height;
return new EllipseResult
{
Center = rotRect.Center,
LongAxis = Math.Max(a, b),
ShortAxis = Math.Min(a, b),
Angle = rotRect.Angle
};
}
/// <summary>
/// 调用 OpenCV fitEllipse 返回原始 RotatedRect
/// </summary>
private static RotatedRect FitEllipseRaw(List<PointF> pts)
{
using var vp = new VectorOfPointF(pts.ToArray());
return CvInvoke.FitEllipse(vp);
}
}
+158
View File
@@ -0,0 +1,158 @@
using System.Drawing;
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using Emgu.CV.Util;
namespace XP.Calibration.Core;
/// <summary>
/// 球心检测器 | Ball center detector from projection images
/// </summary>
public static class BallDetector
{
/// <summary>
/// 是否启用亚像素质心 | Enable sub-pixel centroid
/// </summary>
public static bool EnableSubPixel { get; set; } = true;
/// <summary>
/// 从单帧投影中检测球心 (自适应阈值 + 亚像素质心法)
/// </summary>
public static bool DetectCenter(Mat srcFloat, out PointF center)
{
center = new PointF(-1, -1);
// 归一化到 0-255
using var img8 = new Mat();
CvInvoke.Normalize(srcFloat, img8, 0, 255, NormType.MinMax);
img8.ConvertTo(img8, DepthType.Cv8U);
CvInvoke.GaussianBlur(img8, img8, new Size(3, 3), 0.8);
// 自适应阈值
using var bin = new Mat();
CvInvoke.AdaptiveThreshold(img8, bin, 255,
AdaptiveThresholdType.MeanC, ThresholdType.Binary, 31, -5);
// 查找轮廓
using var contours = new VectorOfVectorOfPoint();
CvInvoke.FindContours(bin, contours, null,
RetrType.External, ChainApproxMethod.ChainApproxNone);
if (contours.Size == 0)
return false;
// 找最大轮廓
int maxIdx = 0;
double maxArea = 0;
for (int i = 0; i < contours.Size; i++)
{
double area = CvInvoke.ContourArea(contours[i]);
if (area > maxArea)
{
maxArea = area;
maxIdx = i;
}
}
// 创建掩膜
using var mask = new Mat(srcFloat.Size, DepthType.Cv8U, 1);
mask.SetTo(new MCvScalar(0));
CvInvoke.DrawContours(mask, contours, maxIdx, new MCvScalar(255), -1);
if (EnableSubPixel)
{
center = SubPixelCentroid(srcFloat, mask);
}
else
{
var moments = CvInvoke.Moments(contours[maxIdx]);
center = new PointF(
(float)(moments.M10 / moments.M00),
(float)(moments.M01 / moments.M00));
}
return center.X >= 0 && center.Y >= 0;
}
/// <summary>
/// 从单帧投影中检测球心 (Canny 边缘 + 椭圆拟合法)
/// </summary>
public static bool DetectCenterByEllipse(Mat srcFloat, out PointF center)
{
center = new PointF(-1, -1);
using var img8 = new Mat();
CvInvoke.Normalize(srcFloat, img8, 0, 255, NormType.MinMax);
img8.ConvertTo(img8, DepthType.Cv8U);
CvInvoke.GaussianBlur(img8, img8, new Size(3, 3), 0.8);
using var edges = new Mat();
CvInvoke.Canny(img8, edges, 30, 80);
using var contours = new VectorOfVectorOfPoint();
CvInvoke.FindContours(edges, contours, null,
RetrType.External, ChainApproxMethod.ChainApproxNone);
if (contours.Size == 0)
return false;
int maxIdx = 0;
double maxArea = 0;
for (int i = 0; i < contours.Size; i++)
{
double area = CvInvoke.ContourArea(contours[i]);
if (area > maxArea)
{
maxArea = area;
maxIdx = i;
}
}
if (contours[maxIdx].Size < 5)
return false;
using var points = new VectorOfPointF(
Array.ConvertAll(contours[maxIdx].ToArray(), p => new PointF(p.X, p.Y)));
var ellipse = CvInvoke.FitEllipse(points);
center = ellipse.Center;
return true;
}
/// <summary>
/// 亚像素质心计算 | Sub-pixel centroid using intensity weighting
/// </summary>
private static PointF SubPixelCentroid(Mat imgFloat, Mat mask)
{
double sumI = 0, sumX = 0, sumY = 0;
var imgData = imgFloat.GetData() as float[,];
var maskData = mask.GetData() as byte[,];
if (imgData == null || maskData == null)
return new PointF(-1, -1);
int rows = imgFloat.Rows;
int cols = imgFloat.Cols;
for (int y = 0; y < rows; y++)
{
for (int x = 0; x < cols; x++)
{
if (maskData[y, x] != 0)
{
double intensity = imgData[y, x];
sumI += intensity;
sumX += x * intensity;
sumY += y * intensity;
}
}
}
if (sumI == 0)
return new PointF(-1, -1);
return new PointF((float)(sumX / sumI), (float)(sumY / sumI));
}
}
+161
View File
@@ -0,0 +1,161 @@
using System.Drawing;
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using Emgu.CV.Util;
namespace XP.Calibration.Core;
/// <summary>
/// 双球心检测结果
/// </summary>
public record DualBallResult
{
/// <summary>第一个球心 (面积较大的)</summary>
public PointF Center1 { get; init; }
/// <summary>第二个球心 (面积较小的)</summary>
public PointF Center2 { get; init; }
/// <summary>两球心距离 (像素)</summary>
public double DistancePx { get; init; }
/// <summary>两球心距离 (mm), 需提供 pixelSize 才有效</summary>
public double DistanceMm { get; init; }
/// <summary>第一个球的轮廓面积</summary>
public double Area1 { get; init; }
/// <summary>第二个球的轮廓面积</summary>
public double Area2 { get; init; }
}
/// <summary>
/// 双球心检测器: 从单帧图像中检测两个球心并计算距离
/// </summary>
public static class DualBallDetector
{
/// <summary>
/// 检测图像中两个最大球体的中心及距离
/// </summary>
/// <param name="srcFloat">输入图像 (CV_32F)</param>
/// <param name="pixelSize">像素物理尺寸 (mm), 0 表示不计算物理距离</param>
/// <param name="minArea">最小轮廓面积阈值, 过滤噪声</param>
/// <param name="enableSubPixel">是否启用亚像素质心</param>
public static DualBallResult? Detect(
Mat srcFloat,
double pixelSize = 0,
double minArea = 50,
bool enableSubPixel = true)
{
// 归一化 + 预处理
using var img8 = new Mat();
CvInvoke.Normalize(srcFloat, img8, 0, 255, NormType.MinMax);
img8.ConvertTo(img8, DepthType.Cv8U);
CvInvoke.GaussianBlur(img8, img8, new Size(3, 3), 0.8);
// 自适应阈值
using var bin = new Mat();
CvInvoke.AdaptiveThreshold(img8, bin, 255,
AdaptiveThresholdType.MeanC, ThresholdType.Binary, 31, -5);
// 查找轮廓
using var contours = new VectorOfVectorOfPoint();
CvInvoke.FindContours(bin, contours, null,
RetrType.External, ChainApproxMethod.ChainApproxNone);
// 按面积排序, 取最大的两个
var candidates = new List<(int Index, double Area)>();
for (int i = 0; i < contours.Size; i++)
{
double area = CvInvoke.ContourArea(contours[i]);
if (area >= minArea)
candidates.Add((i, area));
}
if (candidates.Count < 2)
return null;
candidates.Sort((a, b) => b.Area.CompareTo(a.Area));
var top2 = candidates.Take(2).ToList();
// 计算两个球心
var centers = new PointF[2];
var areas = new double[2];
for (int k = 0; k < 2; k++)
{
int idx = top2[k].Index;
areas[k] = top2[k].Area;
if (enableSubPixel)
{
using var mask = new Mat(srcFloat.Size, DepthType.Cv8U, 1);
mask.SetTo(new MCvScalar(0));
CvInvoke.DrawContours(mask, contours, idx, new MCvScalar(255), -1);
centers[k] = SubPixelCentroid(srcFloat, mask);
}
else
{
var moments = CvInvoke.Moments(contours[idx]);
centers[k] = new PointF(
(float)(moments.M10 / moments.M00),
(float)(moments.M01 / moments.M00));
}
}
// 计算距离
double dx = centers[0].X - centers[1].X;
double dy = centers[0].Y - centers[1].Y;
double distPx = Math.Sqrt(dx * dx + dy * dy);
double distMm = pixelSize > 0 ? distPx * pixelSize : 0;
return new DualBallResult
{
Center1 = centers[0],
Center2 = centers[1],
DistancePx = distPx,
DistanceMm = distMm,
Area1 = areas[0],
Area2 = areas[1]
};
}
/// <summary>
/// 从 8bit 图像检测 (便捷重载, 支持 CV_8U 输入)
/// </summary>
public static DualBallResult? DetectFrom8U(
Mat src8U,
double pixelSize = 0,
double minArea = 50,
bool enableSubPixel = true)
{
using var floatMat = new Mat();
src8U.ConvertTo(floatMat, DepthType.Cv32F);
return Detect(floatMat, pixelSize, minArea, enableSubPixel);
}
private static PointF SubPixelCentroid(Mat imgFloat, Mat mask)
{
double sumI = 0, sumX = 0, sumY = 0;
var imgData = imgFloat.GetData() as float[,];
var maskData = mask.GetData() as byte[,];
if (imgData == null || maskData == null)
return new PointF(-1, -1);
for (int y = 0; y < imgFloat.Rows; y++)
{
for (int x = 0; x < imgFloat.Cols; x++)
{
if (maskData[y, x] != 0)
{
double intensity = imgData[y, x];
sumI += intensity;
sumX += x * intensity;
sumY += y * intensity;
}
}
}
if (sumI == 0)
return new PointF(-1, -1);
return new PointF((float)(sumX / sumI), (float)(sumY / sumI));
}
}
+139
View File
@@ -0,0 +1,139 @@
using XP.Calibration.Models;
namespace XP.Calibration.Core;
/// <summary>
/// TIGRE 投影模型 (角点法) | TIGRE projection model (corner-based, Siddon)
/// </summary>
public static class Projection
{
private struct Point3D
{
public double X, Y, Z;
public Point3D(double x, double y, double z) { X = x; Y = y; Z = z; }
}
/// <summary>
/// 计算物体原点在探测器上的投影像素坐标
/// </summary>
/// <param name="scanAngle">扫描角 (rad)</param>
/// <param name="ay">旋转轴倾斜角 (rad)</param>
/// <param name="detX">探测器 dYaw/Rx (rad)</param>
/// <param name="detY">探测器 dPitch/Ry (rad)</param>
/// <param name="detZ">探测器 dRoll/Rz (rad)</param>
/// <param name="offOrigX">物体 X 偏移 = -R (mm)</param>
/// <param name="offDetecU">探测器 U 偏移 (mm)</param>
/// <param name="offDetecV">探测器 V 偏移 (mm)</param>
/// <param name="gp">几何参数</param>
/// <param name="uPx">输出: U 像素坐标</param>
/// <param name="vPx">输出: V 像素坐标</param>
public static void ProjectPoint(
double scanAngle, double ay,
double detX, double detY, double detZ,
double offOrigX, double offDetecU, double offDetecV,
GeoParams gp,
out double uPx, out double vPx)
{
double ODD = gp.DSD - gp.DSO;
var S = new Point3D(gp.DSO, 0, 0);
// 探测器角点初始化
double py = gp.PixelSize * (0 - (double)gp.NDetecU / 2 + 0.5);
double pz = gp.PixelSize * ((double)gp.NDetecV / 2 - 0.5 - 0);
double puy = gp.PixelSize * (1 - (double)gp.NDetecU / 2 + 0.5);
double pvz = gp.PixelSize * ((double)gp.NDetecV / 2 - 0.5 - 1);
var P = new Point3D(0, py, pz);
var Pu0 = new Point3D(0, puy, pz);
var Pv0 = new Point3D(0, py, pvz);
// Step 1: rollPitchYaw (探测器自身旋转)
RollPitchYaw(detZ, detY, detX, ref P);
RollPitchYaw(detZ, detY, detX, ref Pu0);
RollPitchYaw(detZ, detY, detX, ref Pv0);
// 平移回探测器位置
P.X -= ODD; Pu0.X -= ODD; Pv0.X -= ODD;
// Step 2: offDetecU/V 偏移
P.Y += offDetecU; P.Z += offDetecV;
Pu0.Y += offDetecU; Pu0.Z += offDetecV;
Pv0.Y += offDetecU; Pv0.Z += offDetecV;
// Step 3: eulerZYZ 扫描旋转
EulerZYZ(scanAngle, ay, 0, ref P);
EulerZYZ(scanAngle, ay, 0, ref Pu0);
EulerZYZ(scanAngle, ay, 0, ref Pv0);
EulerZYZ(scanAngle, ay, 0, ref S);
// Step 4: offOrigin 偏移
P.X -= offOrigX; Pu0.X -= offOrigX; Pv0.X -= offOrigX;
S.X -= offOrigX;
// deltaU, deltaV
var deltaU = new Point3D(Pu0.X - P.X, Pu0.Y - P.Y, Pu0.Z - P.Z);
var deltaV = new Point3D(Pv0.X - P.X, Pv0.Y - P.Y, Pv0.Z - P.Z);
// 射线: S → 原点
var ray = new Point3D(-S.X, -S.Y, -S.Z);
// 探测器平面法线
var normal = new Point3D(
deltaU.Y * deltaV.Z - deltaU.Z * deltaV.Y,
deltaU.Z * deltaV.X - deltaU.X * deltaV.Z,
deltaU.X * deltaV.Y - deltaU.Y * deltaV.X);
// 射线与探测器平面交点
double pfsDotN = (P.X - S.X) * normal.X + (P.Y - S.Y) * normal.Y + (P.Z - S.Z) * normal.Z;
double rayDotN = ray.X * normal.X + ray.Y * normal.Y + ray.Z * normal.Z;
if (Math.Abs(rayDotN) < 1e-15)
{
uPx = vPx = -1;
return;
}
double t = pfsDotN / rayDotN;
var hit = new Point3D(S.X + t * ray.X, S.Y + t * ray.Y, S.Z + t * ray.Z);
var hitP = new Point3D(hit.X - P.X, hit.Y - P.Y, hit.Z - P.Z);
// 投影到像素坐标
double dU2 = deltaU.X * deltaU.X + deltaU.Y * deltaU.Y + deltaU.Z * deltaU.Z;
double dV2 = deltaV.X * deltaV.X + deltaV.Y * deltaV.Y + deltaV.Z * deltaV.Z;
double pixelU = (hitP.X * deltaU.X + hitP.Y * deltaU.Y + hitP.Z * deltaU.Z) / dU2;
double pixelV = (hitP.X * deltaV.X + hitP.Y * deltaV.Y + hitP.Z * deltaV.Z) / dV2;
uPx = pixelU;
vPx = (gp.NDetecV - 1) - pixelV; // TIGRE v 翻转
}
/// <summary>
/// rollPitchYaw: Rz(dRoll) * Ry(dPitch) * Rx(dYaw)
/// </summary>
private static void RollPitchYaw(double dRoll, double dPitch, double dYaw, ref Point3D p)
{
double cr = Math.Cos(dRoll), sr = Math.Sin(dRoll);
double cp = Math.Cos(dPitch), sp = Math.Sin(dPitch);
double cy = Math.Cos(dYaw), sy = Math.Sin(dYaw);
double x = p.X, y = p.Y, z = p.Z;
p.X = cr * cp * x + (cr * sp * sy - sr * cy) * y + (cr * sp * cy + sr * sy) * z;
p.Y = sr * cp * x + (sr * sp * sy + cr * cy) * y + (sr * sp * cy - cr * sy) * z;
p.Z = -sp * x + cp * sy * y + cp * cy * z;
}
/// <summary>
/// eulerZYZ: Rz(alpha) * Ry(theta) * Rz(psi)
/// </summary>
private static void EulerZYZ(double alpha, double theta, double psi, ref Point3D p)
{
double ca = Math.Cos(alpha), sa = Math.Sin(alpha);
double ct = Math.Cos(theta), st = Math.Sin(theta);
double cp = Math.Cos(psi), sp = Math.Sin(psi);
double x = p.X, y = p.Y, z = p.Z;
p.X = (ca * ct * cp - sa * sp) * x + (-ca * ct * sp - sa * cp) * y + ca * st * z;
p.Y = (sa * ct * cp + ca * sp) * x + (-sa * ct * sp + ca * cp) * y + sa * st * z;
p.Z = -st * cp * x + st * sp * y + ct * z;
}
}
+92
View File
@@ -0,0 +1,92 @@
using System.Drawing;
using Emgu.CV;
using Emgu.CV.CvEnum;
namespace XP.Calibration.Core;
/// <summary>
/// RAW 投影数据读取器 | RAW projection data reader
/// </summary>
public static class RawReader
{
/// <summary>
/// 从 RAW 文件读取 float32 投影序列
/// </summary>
/// <param name="path">文件路径</param>
/// <param name="width">图像宽度</param>
/// <param name="height">图像高度</param>
/// <param name="count">投影帧数</param>
/// <returns>Mat 列表 (CV_32F)</returns>
public static List<Mat> ReadFloat32(string path, int width, int height, int count)
{
var projections = new List<Mat>(count);
int frameBytes = width * height * sizeof(float);
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[frameBytes];
for (int i = 0; i < count; i++)
{
int bytesRead = stream.Read(buffer, 0, frameBytes);
if (bytesRead < frameBytes)
throw new EndOfStreamException(
$"帧 {i} 数据不足: 期望 {frameBytes} 字节, 实际 {bytesRead} 字节");
var mat = new Mat(height, width, DepthType.Cv32F, 1);
System.Runtime.InteropServices.Marshal.Copy(buffer, 0, mat.DataPointer, frameBytes);
projections.Add(mat);
}
return projections;
}
/// <summary>
/// 读取单张 TIFF 图像, 返回 CV_32F 灰度 Mat
/// </summary>
/// <param name="path">TIFF 文件路径</param>
/// <returns>Mat (CV_32F, 单通道)</returns>
public static Mat ReadTiff(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"TIFF 文件不存在: {path}");
// 以原始深度读取 (支持 8/16/32bit TIFF)
var src = CvInvoke.Imread(path, ImreadModes.AnyDepth | ImreadModes.Grayscale);
if (src.IsEmpty)
throw new InvalidOperationException($"无法读取 TIFF: {path}");
// 统一转为 CV_32F
if (src.Depth != DepthType.Cv32F)
{
var dst = new Mat();
src.ConvertTo(dst, DepthType.Cv32F);
src.Dispose();
return dst;
}
return src;
}
/// <summary>
/// 批量读取多张 TIFF 图像 (按文件名排序)
/// </summary>
/// <param name="directory">TIFF 文件所在目录</param>
/// <param name="pattern">搜索模式, 默认 "*.tif"</param>
/// <returns>Mat 列表 (CV_32F)</returns>
public static List<Mat> ReadTiffSequence(string directory, string pattern = "*.tif")
{
if (!Directory.Exists(directory))
throw new DirectoryNotFoundException($"目录不存在: {directory}");
var files = Directory.GetFiles(directory, pattern)
.Concat(Directory.GetFiles(directory, pattern + "f")) // 同时匹配 .tif 和 .tiff
.Distinct()
.OrderBy(f => f)
.ToList();
if (files.Count == 0)
throw new FileNotFoundException($"目录中未找到 TIFF 文件: {directory}");
return files.Select(ReadTiff).ToList();
}
}
+363
View File
@@ -0,0 +1,363 @@
using System.Drawing;
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using Emgu.CV.Util;
using XP.Calibration.Core;
using XP.Calibration.Models;
namespace XP.Calibration;
/// <summary>
/// 完整几何校准: TIGRE 投影模型 + LM 优化
/// Full geometric calibration: TIGRE projection model + Levenberg-Marquardt optimization
/// </summary>
public class FullCalibration
{
// 参数索引常量
private const int IDX_AY = 0;
private const int IDX_DET_Y = 1;
private const int IDX_DET_X = 2;
private const int IDX_DET_Z = 3;
private const int IDX_R = 4;
private const int BASE_PARAMS = 5;
// 参数边界
private static readonly double[] LowerBase = { -Math.PI / 2, -Math.PI / 3, -Math.PI / 3, -Math.PI, 0.1 };
private static readonly double[] UpperBase = { Math.PI / 2, Math.PI / 3, Math.PI / 3, Math.PI, 100 };
/// <summary>
/// 进度回调 | Progress callback (iteration, rms, params)
/// </summary>
public Action<int, double, double[]>? OnProgress { get; set; }
/// <summary>
/// 从投影序列执行完整几何校准
/// </summary>
public FullCalibrationResult Calibrate(
List<Mat> projections, GeoParams geo, FullCalibrationOptions? options = null)
{
options ??= new FullCalibrationOptions();
// 检测各帧球心
var thetas = new List<double>();
var uObs = new List<double>();
var vObs = new List<double>();
var pts = new List<PointF>();
for (int i = 0; i < projections.Count; i++)
{
if (BallDetector.DetectCenter(projections[i], out var c))
{
thetas.Add((double)i / (projections.Count - 1) * 2.0 * Math.PI);
uObs.Add(c.X);
vObs.Add(c.Y);
pts.Add(c);
}
}
if (pts.Count <= 10)
throw new InvalidOperationException($"有效检测帧数不足: {pts.Count}");
// 椭圆拟合估算初值
using var vp = new VectorOfPointF(pts.ToArray());
var ell = CvInvoke.FitEllipse(vp);
double longAxis = Math.Max(ell.Size.Width, ell.Size.Height);
double shortAxis = Math.Min(ell.Size.Width, ell.Size.Height);
double mag = geo.DSD / geo.DSO;
double rInit = longAxis * geo.PixelSize / (2.0 * mag);
double ayInit = Math.Acos(Math.Min(1.0, shortAxis / longAxis));
// 确定参数个数和索引
int np = BASE_PARAMS;
int idxOffDU = -1, idxOffDV = -1, idxDSO = -1, idxDSD = -1;
if (options.OptimizeDetectorOffset)
{
idxOffDU = np; idxOffDV = np + 1; np += 2;
}
if (options.OptimizeDistances)
{
idxDSO = np; idxDSD = np + 1; np += 2;
}
// 构造初始参数
var p = new double[np];
p[IDX_AY] = ayInit;
p[IDX_DET_Y] = 0;
p[IDX_DET_X] = 0;
p[IDX_DET_Z] = 0;
p[IDX_R] = rInit;
if (options.OptimizeDetectorOffset)
{
p[idxOffDU] = 0; p[idxOffDV] = 0;
}
if (options.OptimizeDistances)
{
p[idxDSO] = geo.DSO; p[idxDSD] = geo.DSD;
}
// LM 优化
var ctx = new LmContext(thetas, uObs, vObs, geo, np,
options.OptimizeDetectorOffset, options.OptimizeDistances,
idxOffDU, idxOffDV, idxDSO, idxDSD);
bool converged = SolveLM(p, ctx, options.MaxIterations, options.Tolerance);
// 计算最终 RMS
var finalRes = ComputeResiduals(p, ctx);
double rms = Math.Sqrt(finalRes.Sum(r => r * r) / finalRes.Length);
double dsoOut = options.OptimizeDistances ? p[idxDSO] : geo.DSO;
double dsdOut = options.OptimizeDistances ? p[idxDSD] : geo.DSD;
return new FullCalibrationResult
{
AyDeg = p[IDX_AY] * 180.0 / Math.PI,
DetYDeg = p[IDX_DET_Y] * 180.0 / Math.PI,
DetXDeg = p[IDX_DET_X] * 180.0 / Math.PI,
DetZDeg = p[IDX_DET_Z] * 180.0 / Math.PI,
R_mm = p[IDX_R],
OffDetecU_mm = options.OptimizeDetectorOffset ? p[idxOffDU] : 0,
OffDetecV_mm = options.OptimizeDetectorOffset ? p[idxOffDV] : 0,
DSO_mm = dsoOut,
DSD_mm = dsdOut,
RmsPx = rms,
Converged = converged
};
}
/// <summary>
/// 从 RAW 文件执行完整几何校准 (便捷方法)
/// </summary>
public FullCalibrationResult CalibrateFromRaw(
string rawPath, int width, int height, int count,
GeoParams geo, FullCalibrationOptions? options = null)
{
var projections = RawReader.ReadFloat32(rawPath, width, height, count);
try
{
return Calibrate(projections, geo, options);
}
finally
{
foreach (var p in projections) p.Dispose();
}
}
#region LM Solver
private record LmContext(
List<double> Thetas, List<double> UObs, List<double> VObs,
GeoParams Geo, int NP,
bool OptOffset, bool OptDist,
int IdxOffDU, int IdxOffDV, int IdxDSO, int IdxDSD);
private static double[] ComputeResiduals(double[] p, LmContext ctx)
{
double ay = p[IDX_AY], detY = p[IDX_DET_Y];
double detX = p[IDX_DET_X], detZ = p[IDX_DET_Z], R = p[IDX_R];
double offU = ctx.OptOffset ? p[ctx.IdxOffDU] : 0;
double offV = ctx.OptOffset ? p[ctx.IdxOffDV] : 0;
var gp = new GeoParams
{
DSO = ctx.OptDist ? p[ctx.IdxDSO] : ctx.Geo.DSO,
DSD = ctx.OptDist ? p[ctx.IdxDSD] : ctx.Geo.DSD,
PixelSize = ctx.Geo.PixelSize,
NDetecU = ctx.Geo.NDetecU,
NDetecV = ctx.Geo.NDetecV
};
int N = ctx.Thetas.Count;
var res = new double[2 * N];
for (int i = 0; i < N; i++)
{
Projection.ProjectPoint(
ctx.Thetas[i], ay, detX, detY, detZ,
-R, offU, offV, gp,
out double u, out double v);
res[2 * i] = u - ctx.UObs[i];
res[2 * i + 1] = v - ctx.VObs[i];
}
return res;
}
private static double[,] ComputeJacobian(double[] p, LmContext ctx)
{
int nObs = 2 * ctx.Thetas.Count;
int np = ctx.NP;
var J = new double[nObs, np];
double eps = 1e-7;
for (int j = 0; j < np; j++)
{
double[] pp = (double[])p.Clone();
double[] pm = (double[])p.Clone();
double step = Math.Max(eps, Math.Abs(p[j]) * eps);
pp[j] = p[j] + step;
pm[j] = p[j] - step;
var rp = ComputeResiduals(pp, ctx);
var rm = ComputeResiduals(pm, ctx);
double denom = 2.0 * step;
for (int i = 0; i < nObs; i++)
J[i, j] = (rp[i] - rm[i]) / denom;
}
return J;
}
private static void ClampParams(double[] p, LmContext ctx)
{
for (int i = 0; i < BASE_PARAMS; i++)
p[i] = Math.Clamp(p[i], LowerBase[i], UpperBase[i]);
if (ctx.OptOffset)
{
p[ctx.IdxOffDU] = Math.Clamp(p[ctx.IdxOffDU], -50, 50);
p[ctx.IdxOffDV] = Math.Clamp(p[ctx.IdxOffDV], -50, 50);
}
if (ctx.OptDist)
{
p[ctx.IdxDSO] = Math.Clamp(p[ctx.IdxDSO], 50, 1000);
p[ctx.IdxDSD] = Math.Clamp(p[ctx.IdxDSD], 100, 2000);
if (p[ctx.IdxDSD] <= p[ctx.IdxDSO])
p[ctx.IdxDSD] = p[ctx.IdxDSO] + 10;
}
}
private bool SolveLM(double[] p, LmContext ctx, int maxIter, double tol)
{
double lambda = 1e-3;
var res = ComputeResiduals(p, ctx);
int m = res.Length;
int np = ctx.NP;
double cost = res.Sum(r => r * r);
for (int iter = 0; iter < maxIter; iter++)
{
var J = ComputeJacobian(p, ctx);
// JtJ = J^T * J, Jtr = J^T * r
var JtJ = new double[np, np];
var Jtr = new double[np];
for (int i = 0; i < np; i++)
{
for (int j = i; j < np; j++)
{
double sum = 0;
for (int k = 0; k < m; k++)
sum += J[k, i] * J[k, j];
JtJ[i, j] = sum;
JtJ[j, i] = sum;
}
double s = 0;
for (int k = 0; k < m; k++)
s += J[k, i] * res[k];
Jtr[i] = s;
}
// A = JtJ + lambda * diag(1 + JtJ)
var A = new double[np, np];
var b = new double[np];
Array.Copy(JtJ, A, JtJ.Length);
for (int i = 0; i < np; i++)
{
A[i, i] += lambda * (1.0 + JtJ[i, i]);
b[i] = -Jtr[i];
}
// 解线性方程组 (Cholesky)
if (!SolveLinear(A, b, np, out var delta))
{
lambda *= 10;
continue;
}
// 尝试更新
var np2 = new double[np];
for (int i = 0; i < np; i++)
np2[i] = p[i] + delta[i];
ClampParams(np2, ctx);
var newRes = ComputeResiduals(np2, ctx);
double newCost = newRes.Sum(r => r * r);
if (newCost < cost)
{
double improvement = cost - newCost;
Array.Copy(np2, p, np);
res = newRes;
cost = newCost;
lambda *= 0.3;
if (lambda < 1e-12) lambda = 1e-12;
OnProgress?.Invoke(iter, Math.Sqrt(cost / m), p);
if (improvement < tol)
return true;
}
else
{
lambda *= 10;
if (lambda > 1e12) lambda = 1e12;
}
}
return false;
}
/// <summary>
/// 简单的 Cholesky 分解求解 Ax = b
/// </summary>
private static bool SolveLinear(double[,] A, double[] b, int n, out double[] x)
{
x = new double[n];
var L = new double[n, n];
// Cholesky: A = L * L^T
for (int i = 0; i < n; i++)
{
for (int j = 0; j <= i; j++)
{
double sum = 0;
for (int k = 0; k < j; k++)
sum += L[i, k] * L[j, k];
if (i == j)
{
double val = A[i, i] - sum;
if (val <= 0) return false;
L[i, j] = Math.Sqrt(val);
}
else
{
L[i, j] = (A[i, j] - sum) / L[j, j];
}
}
}
// 前代: L * y = b
var y = new double[n];
for (int i = 0; i < n; i++)
{
double sum = 0;
for (int k = 0; k < i; k++)
sum += L[i, k] * y[k];
y[i] = (b[i] - sum) / L[i, i];
}
// 回代: L^T * x = y
for (int i = n - 1; i >= 0; i--)
{
double sum = 0;
for (int k = i + 1; k < n; k++)
sum += L[k, i] * x[k];
x[i] = (y[i] - sum) / L[i, i];
}
return true;
}
#endregion
}
@@ -0,0 +1,52 @@
using System.Drawing;
namespace XP.Calibration.Models;
/// <summary>
/// 椭圆拟合结果 | Ellipse fitting result
/// </summary>
public record EllipseResult
{
public PointF Center { get; init; }
public float LongAxis { get; init; }
public float ShortAxis { get; init; }
public float Angle { get; init; }
}
/// <summary>
/// 几何参数 | Geometry parameters for CT system
/// </summary>
public class GeoParams
{
/// <summary>焦点到旋转中心距离 (mm) | Distance Source to Origin</summary>
public double DSO { get; set; }
/// <summary>焦点到探测器距离 (mm) | Distance Source to Detector</summary>
public double DSD { get; set; }
/// <summary>探测器像素大小 (mm) | Detector pixel size</summary>
public double PixelSize { get; set; }
/// <summary>探测器水平像素数 | Detector horizontal pixel count</summary>
public int NDetecU { get; set; }
/// <summary>探测器垂直像素数 | Detector vertical pixel count</summary>
public int NDetecV { get; set; }
}
/// <summary>
/// 中心校准结果 | Center calibration result
/// </summary>
public record CenterCalibrationResult
{
/// <summary>椭圆拟合结果</summary>
public EllipseResult Ellipse { get; init; } = null!;
/// <summary>倾斜角 (度) | Tilt angle in degrees</summary>
public double AlphaDeg { get; init; }
/// <summary>反算半径 R (mm)</summary>
public double R_mm { get; init; }
/// <summary>透视偏移量 (像素) | Perspective offset in pixels</summary>
public double DeltaPx { get; init; }
/// <summary>修正后焦点投影 U 坐标</summary>
public double FocalU { get; init; }
/// <summary>修正后焦点投影 V 坐标</summary>
public double FocalV { get; init; }
/// <summary>各帧检测到的球心坐标</summary>
public List<PointF> DetectedCenters { get; init; } = new();
}
@@ -0,0 +1,16 @@
namespace XP.Calibration.Models;
/// <summary>
/// 完整几何校准选项 | Options for full geometric calibration
/// </summary>
public class FullCalibrationOptions
{
/// <summary>是否优化探测器偏移 offDetecU/V</summary>
public bool OptimizeDetectorOffset { get; set; } = false;
/// <summary>是否优化 DSO/DSD 距离</summary>
public bool OptimizeDistances { get; set; } = false;
/// <summary>LM 最大迭代次数</summary>
public int MaxIterations { get; set; } = 5000;
/// <summary>收敛阈值</summary>
public double Tolerance { get; set; } = 1e-16;
}
@@ -0,0 +1,30 @@
namespace XP.Calibration.Models;
/// <summary>
/// 完整几何校准结果 | Full geometric calibration result
/// </summary>
public record FullCalibrationResult
{
/// <summary>旋转轴倾斜角 ay (度) | Rotation axis tilt, angles[:,1]</summary>
public double AyDeg { get; init; }
/// <summary>探测器 dPitch (度) | Detector pitch, rotDet[:,1]</summary>
public double DetYDeg { get; init; }
/// <summary>探测器 dYaw (度) | Detector yaw, rotDet[:,0]</summary>
public double DetXDeg { get; init; }
/// <summary>探测器 dRoll (度) | Detector roll, rotDet[:,2]</summary>
public double DetZDeg { get; init; }
/// <summary>物体偏移 R (mm) | Object offset radius</summary>
public double R_mm { get; init; }
/// <summary>探测器水平偏移 (mm) | Detector U offset</summary>
public double OffDetecU_mm { get; init; }
/// <summary>探测器垂直偏移 (mm) | Detector V offset</summary>
public double OffDetecV_mm { get; init; }
/// <summary>优化后 DSO (mm)</summary>
public double DSO_mm { get; init; }
/// <summary>优化后 DSD (mm)</summary>
public double DSD_mm { get; init; }
/// <summary>RMS 残差 (像素) | RMS residual in pixels</summary>
public double RmsPx { get; init; }
/// <summary>是否收敛 | Whether optimization converged</summary>
public bool Converged { get; init; }
}
+159
View File
@@ -0,0 +1,159 @@
# XP.Calibration
平面 CT 系统几何校准库,基于 .NET 8 + Emgu.CV,从 C++ OpenCV 实现移植而来。
## 功能
提供两种校准方法:
- **中心校准 (CenterCalibration)** — 从投影序列检测球心轨迹,椭圆拟合后反算倾斜角和焦点投影偏移,适用于快速估算
- **完整几何校准 (FullCalibration)** — 基于 TIGRE 投影模型(角点法),使用 Levenberg-Marquardt 优化器同时优化 5~9 个几何参数
## 项目结构
```
XP.Calibration/
├── Models/
│ ├── CalibrationModels.cs # EllipseResult, GeoParams, CenterCalibrationResult
│ ├── FullCalibrationResult.cs # 完整校准输出 (各参数 + RMS)
│ └── FullCalibrationOptions.cs # 优化模式选项
├── Core/
│ ├── RawReader.cs # RAW float32 投影数据读取
│ ├── BallDetector.cs # 球心检测 (自适应阈值+亚像素质心 / Canny+椭圆拟合)
│ └── TigreProjection.cs # TIGRE 投影模型 (rollPitchYaw + eulerZYZ)
├── CenterCalibration.cs # 中心校准
└── FullCalibration.cs # 完整几何校准 (LM 优化)
```
## 校准参数
完整几何校准支持三种模式:
| 模式 | 参数数 | 优化参数 |
|------|--------|----------|
| 基础 | 5 | ay, det_y, det_x, det_z, R |
| +探测器偏移 | 7 | 基础 + offDetecU, offDetecV |
| +距离 | 9 | 基础 + offDetecU, offDetecV + DSO, DSD |
参数含义:
| 参数 | 说明 | TIGRE 对应 |
|------|------|-----------|
| ay | 旋转轴倾斜角 | angles[:,1] |
| det_x | 探测器 dYaw (Rx) | rotDetector[:,0] |
| det_y | 探测器 dPitch (Ry) | rotDetector[:,1] |
| det_z | 探测器 dRoll (Rz) | rotDetector[:,2] |
| R | 物体偏移半径 | offOrigin[:,2] = -R |
| offDetecU/V | 探测器平移偏移 | offDetector[:,0]/[:,1] |
| DSO | 焦点到旋转中心距离 | geo.DSO |
| DSD | 焦点到探测器距离 | geo.DSD |
## 使用示例
### 中心校准
```csharp
var geo = new GeoParams
{
DSO = 200, DSD = 500, PixelSize = 0.6,
NDetecU = 512, NDetecV = 512
};
var calib = new CenterCalibration();
var result = calib.CalibrateFromRaw("projections.raw", 512, 512, 360, geo);
Console.WriteLine($"倾斜角: {result.AlphaDeg:F2}°");
Console.WriteLine($"R: {result.R_mm:F2} mm");
Console.WriteLine($"焦点投影: ({result.FocalU:F2}, {result.FocalV:F2})");
```
### 完整几何校准 (5 参数)
```csharp
var geo = new GeoParams
{
DSO = 250, DSD = 450, PixelSize = 0.6,
NDetecU = 512, NDetecV = 512
};
var full = new FullCalibration();
var result = full.CalibrateFromRaw("projections.raw", 512, 512, 360, geo);
Console.WriteLine($"ay: {result.AyDeg:F4}°");
Console.WriteLine($"det_z: {result.DetZDeg:F4}°");
Console.WriteLine($"R: {result.R_mm:F4} mm");
Console.WriteLine($"RMS: {result.RmsPx:F6} px");
```
### 完整几何校准 (9 参数)
```csharp
var options = new FullCalibrationOptions
{
OptimizeDetectorOffset = true, // +offDetecU/V
OptimizeDistances = true, // +DSO/DSD
MaxIterations = 5000,
Tolerance = 1e-16
};
var full = new FullCalibration();
// 可选: 监听优化进度
full.OnProgress = (iter, rms, p) =>
Console.WriteLine($"Iter {iter}: RMS={rms:F6} px");
var result = full.CalibrateFromRaw("projections.raw", 512, 512, 360, geo, options);
```
### 直接传入 Mat 列表
如果投影数据已经在内存中(比如从相机采集),可以直接传 `List<Mat>`
```csharp
List<Mat> projections = ...; // 已有的 CV_32F Mat 列表
var result = new FullCalibration().Calibrate(projections, geo, options);
```
### 切换球心检测方法
默认使用自适应阈值 + 亚像素质心法。如需使用 Canny + 椭圆拟合法:
```csharp
// 关闭亚像素质心,改用矩心
BallDetector.EnableSubPixel = false;
// 或直接调用椭圆拟合检测
BallDetector.DetectCenterByEllipse(mat, out var center);
```
## 算法说明
### 球心检测流程
1. 归一化 float32 → 8bit
2. 高斯模糊 (3×3, σ=0.8)
3. 自适应阈值二值化 (blockSize=31, C=-5)
4. 查找最大轮廓
5. 亚像素质心 (强度加权) 或矩心
### TIGRE 投影模型
完全按照 TIGRE `computeDeltas_Siddon` 流程实现:
1. 初始化探测器角点 (P, Pu0, Pv0) 和射线源 S
2. `rollPitchYaw` — 探测器自身旋转 (det_x, det_y, det_z)
3. 探测器偏移 (offDetecU/V)
4. `eulerZYZ` — 扫描旋转 (scanAngle, ay)
5. 物体偏移 (offOrigX = -R)
6. 射线-平面交点 → 像素坐标
### LM 优化器
- 数值差分雅可比矩阵 (中心差分, ε=1e-7)
- Cholesky 分解求解法方程
- 自适应阻尼因子 (成功 ×0.3, 失败 ×10)
- 参数边界约束 (clamp)
## 依赖
- .NET 8.0-windows
- Emgu.CV 4.10.0.5680
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>XP.Calibration</RootNamespace>
<AssemblyName>XP.Calibration</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Emgu.CV" Version="4.10.0.5680" />
<PackageReference Include="Emgu.CV.runtime.windows" Version="4.10.0.5680" />
</ItemGroup>
</Project>
@@ -1878,7 +1878,10 @@
"Telerik.UI.for.Wpf.NetCore.Xaml": "2024.1.408"
},
"runtime": {
"XP.Common.dll": {}
"XP.Common.dll": {
"assemblyVersion": "1.4.16.1",
"fileVersion": "1.4.16.1"
}
},
"resources": {
"en-US/XP.Common.resources.dll": {
@@ -0,0 +1,31 @@
using System.Windows;
namespace XP.ImageProcessing.RoiControl.Controls
{
/// <summary>测量完成事件参数</summary>
public class MeasureCompletedEventArgs : RoutedEventArgs
{
public Point P1 { get; }
public Point P2 { get; }
public double Distance { get; }
public int TotalCount { get; }
public string MeasureType { get; set; }
public MeasureCompletedEventArgs(RoutedEvent routedEvent, Point p1, Point p2, double distance, int totalCount)
: base(routedEvent)
{
P1 = p1; P2 = p2; Distance = distance; TotalCount = totalCount;
}
}
/// <summary>测量状态变化事件参数</summary>
public class MeasureStatusEventArgs : RoutedEventArgs
{
public string Message { get; }
public MeasureStatusEventArgs(RoutedEvent routedEvent, string message) : base(routedEvent)
{
Message = message;
}
}
}
@@ -17,35 +17,20 @@
</UserControl.Resources>
<Border BorderBrush="Transparent" BorderThickness="1" ClipToBounds="True">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 左侧控制按钮 -->
<Border Grid.Column="0" Background="White" Padding="5">
<StackPanel Orientation="Vertical" VerticalAlignment="Top">
<Button x:Name="btnZoomIn" Content="+" Background="White" BorderBrush="LightGray" Width="40" Height="40" Margin="2" Click="BtnZoomIn_Click" />
<Button x:Name="btnZoomOut" Content="-" Background="White" BorderBrush="LightGray" Width="40" Height="40" Margin="2" Click="BtnZoomOut_Click" />
<Button x:Name="btnReset" Content="适应" Background="White" BorderBrush="LightGray" Width="40" Height="40" Margin="2" Click="BtnReset_Click" />
</StackPanel>
</Border>
<!-- 图像显示区域 -->
<Grid Grid.Column="1" x:Name="imageDisplayGrid" ClipToBounds="True">
<Grid x:Name="imageDisplayGrid" ClipToBounds="True">
<Grid x:Name="transformGrid"
RenderTransformOrigin="0,0"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Grid.LayoutTransform>
<ScaleTransform x:Name="scaleTransform"
ScaleX="{Binding ZoomScale, ElementName=root}"
ScaleY="{Binding ZoomScale, ElementName=root}" />
</Grid.LayoutTransform>
<Grid.RenderTransform>
<TransformGroup>
<ScaleTransform x:Name="scaleTransform"
ScaleX="{Binding ZoomScale, ElementName=root}"
ScaleY="{Binding ZoomScale, ElementName=root}" />
<TranslateTransform x:Name="translateTransform"
X="{Binding PanOffsetX, ElementName=root}"
Y="{Binding PanOffsetY, ElementName=root}" />
</TransformGroup>
<TranslateTransform x:Name="translateTransform"
X="{Binding PanOffsetX, ElementName=root}"
Y="{Binding PanOffsetY, ElementName=root}" />
</Grid.RenderTransform>
<Canvas x:Name="mainCanvas"
@@ -7,6 +7,7 @@ using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
namespace XP.ImageProcessing.RoiControl.Controls
@@ -120,11 +121,23 @@ namespace XP.ImageProcessing.RoiControl.Controls
private static void OnImageSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (PolygonRoiCanvas)d;
if (e.NewValue is ImageSource imageSource)
if (e.NewValue is BitmapSource bitmap)
{
// 使用像素尺寸,避免 DPI 不同导致 DIP 尺寸与实际像素不一致
control.CanvasWidth = bitmap.PixelWidth;
control.CanvasHeight = bitmap.PixelHeight;
control.ResetView();
}
else if (e.NewValue is ImageSource imageSource)
{
control.CanvasWidth = imageSource.Width;
control.CanvasHeight = imageSource.Height;
control.ResetView();
}
// 图像尺寸变化后刷新十字线
if (control.ShowCrosshair)
control.AddCrosshair();
}
public static readonly DependencyProperty ROIItemsProperty =
@@ -234,6 +247,417 @@ namespace XP.ImageProcessing.RoiControl.Controls
#endregion Dependency Properties
#region Measurement Config
public static readonly DependencyProperty PixelSizeProperty =
DependencyProperty.Register(nameof(PixelSize), typeof(double), typeof(PolygonRoiCanvas),
new PropertyMetadata(1.0));
/// <summary>每像素对应的物理尺寸(= 探测器像素尺寸 / 放大倍率),默认 1.0</summary>
public double PixelSize
{
get => (double)GetValue(PixelSizeProperty);
set => SetValue(PixelSizeProperty, value);
}
public static readonly DependencyProperty MeasureUnitProperty =
DependencyProperty.Register(nameof(MeasureUnit), typeof(string), typeof(PolygonRoiCanvas),
new PropertyMetadata("px"));
/// <summary>测量单位,默认 "px",可设为 "mm"/"μm"/"cm"</summary>
public string MeasureUnit
{
get => (string)GetValue(MeasureUnitProperty);
set => SetValue(MeasureUnitProperty, value);
}
/// <summary>将像素距离转换为物理距离文本</summary>
internal string FormatDistance(double pixelDistance)
{
string unit = MeasureUnit ?? "px";
if (unit == "px" || PixelSize <= 0 || PixelSize == 1.0)
return $"{pixelDistance:F2} px";
double physical = pixelDistance * PixelSize;
return $"{physical:F4} {unit}";
}
#endregion Measurement Config
#region Crosshair
public static readonly DependencyProperty ShowCrosshairProperty =
DependencyProperty.Register(nameof(ShowCrosshair), typeof(bool), typeof(PolygonRoiCanvas),
new PropertyMetadata(false, OnShowCrosshairChanged));
public bool ShowCrosshair
{
get => (bool)GetValue(ShowCrosshairProperty);
set => SetValue(ShowCrosshairProperty, value);
}
private Line _crosshairH, _crosshairV;
private static void OnShowCrosshairChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var c = (PolygonRoiCanvas)d;
if ((bool)e.NewValue)
c.AddCrosshair();
else
c.RemoveCrosshair();
}
private void AddCrosshair()
{
RemoveCrosshair();
double w = CanvasWidth, h = CanvasHeight;
if (w <= 0 || h <= 0) return;
_crosshairH = new Line { X1 = 0, Y1 = h / 2, X2 = w, Y2 = h / 2, Stroke = Brushes.Red, StrokeThickness = 1, Opacity = 0.7, IsHitTestVisible = false };
_crosshairV = new Line { X1 = w / 2, Y1 = 0, X2 = w / 2, Y2 = h, Stroke = Brushes.Red, StrokeThickness = 1, Opacity = 0.7, IsHitTestVisible = false };
mainCanvas.Children.Add(_crosshairH);
mainCanvas.Children.Add(_crosshairV);
}
private void RemoveCrosshair()
{
if (_crosshairH != null) { mainCanvas.Children.Remove(_crosshairH); _crosshairH = null; }
if (_crosshairV != null) { mainCanvas.Children.Remove(_crosshairV); _crosshairV = null; }
}
#endregion Crosshair
#region Measurement
public static readonly DependencyProperty CurrentMeasureModeProperty =
DependencyProperty.Register(nameof(CurrentMeasureMode), typeof(Models.MeasureMode), typeof(PolygonRoiCanvas),
new PropertyMetadata(Models.MeasureMode.None, OnMeasureModeChanged));
public Models.MeasureMode CurrentMeasureMode
{
get => (Models.MeasureMode)GetValue(CurrentMeasureModeProperty);
set => SetValue(CurrentMeasureModeProperty, value);
}
// 保留 IsMeasuring 作为便捷属性
public bool IsMeasuring => CurrentMeasureMode != Models.MeasureMode.None;
private Canvas _measureOverlay;
private readonly System.Collections.Generic.List<Models.MeasureGroup> _ppGroups = new();
private readonly System.Collections.Generic.List<Models.PointToLineGroup> _ptlGroups = new();
// 点点距临时状态
private Ellipse _pendingDot;
private Point? _pendingPoint;
// 点线距临时状态
private int _ptlClickCount;
private Ellipse _ptlTempDot1, _ptlTempDot2;
private Line _ptlTempLine;
private Point? _ptlTempL1, _ptlTempL2;
// 拖拽状态
private Ellipse _mDraggingDot;
private object _mDraggingOwner; // MeasureGroup 或 PointToLineGroup
private string _mDraggingRole; // "Dot1","Dot2","DotL1","DotL2","DotP"
private static void OnMeasureModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var c = (PolygonRoiCanvas)d;
var newMode = (Models.MeasureMode)e.NewValue;
if (newMode != Models.MeasureMode.None)
{
c.EnsureMeasureOverlay();
}
else
{
// 退出测量模式:清除未完成的临时元素
c.ClearPendingElements();
}
}
private void ClearPendingElements()
{
if (_measureOverlay == null) return;
if (_pendingDot != null) { _measureOverlay.Children.Remove(_pendingDot); _pendingDot = null; }
_pendingPoint = null;
if (_ptlTempDot1 != null) { _measureOverlay.Children.Remove(_ptlTempDot1); _ptlTempDot1 = null; }
if (_ptlTempDot2 != null) { _measureOverlay.Children.Remove(_ptlTempDot2); _ptlTempDot2 = null; }
if (_ptlTempLine != null) { _measureOverlay.Children.Remove(_ptlTempLine); _ptlTempLine = null; }
_ptlTempL1 = _ptlTempL2 = null;
_ptlClickCount = 0;
}
private void EnsureMeasureOverlay()
{
if (_measureOverlay != null) return;
_measureOverlay = new Canvas { IsHitTestVisible = true, Background = Brushes.Transparent };
_measureOverlay.SetBinding(Canvas.WidthProperty, new System.Windows.Data.Binding("CanvasWidth") { Source = this });
_measureOverlay.SetBinding(Canvas.HeightProperty, new System.Windows.Data.Binding("CanvasHeight") { Source = this });
mainCanvas.Children.Add(_measureOverlay);
}
private void RemoveMeasureOverlay()
{
if (_measureOverlay != null) { mainCanvas.Children.Remove(_measureOverlay); _measureOverlay = null; }
_ppGroups.Clear();
_ptlGroups.Clear();
_pendingDot = null; _pendingPoint = null;
_ptlTempDot1 = _ptlTempDot2 = null; _ptlTempLine = null;
_ptlTempL1 = _ptlTempL2 = null; _ptlClickCount = 0;
_mDraggingDot = null; _mDraggingOwner = null;
}
public void ClearMeasurements() => RemoveMeasureOverlay();
public int MeasureCount => _ppGroups.Count + _ptlGroups.Count;
// ── 点击分发 ──
private void HandleMeasureClick(Point pos)
{
if (_measureOverlay == null) EnsureMeasureOverlay();
if (_measureOverlay == null) return;
if (CurrentMeasureMode == Models.MeasureMode.PointDistance)
HandlePointDistanceClick(pos);
else if (CurrentMeasureMode == Models.MeasureMode.PointToLine)
HandlePointToLineClick(pos);
}
// ── 点点距 ──
private void HandlePointDistanceClick(Point pos)
{
if (!_pendingPoint.HasValue)
{
_pendingPoint = pos;
_pendingDot = CreateMDot(Brushes.Red);
_measureOverlay.Children.Add(_pendingDot);
SetDotPos(_pendingDot, pos);
RaiseMeasureStatusChanged($"点点距 - 第一点: ({pos.X:F0}, {pos.Y:F0}),请点击第二个点");
}
else
{
var g = CreatePPGroup(_pendingPoint.Value, pos);
_ppGroups.Add(g);
_measureOverlay.Children.Remove(_pendingDot);
_pendingDot = null; _pendingPoint = null;
RaiseMeasureCompleted(g.P1, g.P2, g.Distance, MeasureCount, "PointDistance");
CurrentMeasureMode = Models.MeasureMode.None;
}
}
private Models.MeasureGroup CreatePPGroup(Point p1, Point p2)
{
var g = new Models.MeasureGroup { P1 = p1, P2 = p2 };
g.Line = new Line { Stroke = Brushes.Lime, StrokeThickness = 2, IsHitTestVisible = false };
g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false };
g.Dot1 = CreateMDot(Brushes.Red);
g.Dot2 = CreateMDot(Brushes.Blue);
foreach (UIElement el in new UIElement[] { g.Line, g.Label, g.Dot1, g.Dot2 })
_measureOverlay.Children.Add(el);
SetDotPos(g.Dot1, p1); SetDotPos(g.Dot2, p2);
g.UpdateLine(); g.UpdateLabel(FormatDistance(g.Distance));
return g;
}
// ── 点线距 ──
private void HandlePointToLineClick(Point pos)
{
_ptlClickCount++;
if (_ptlClickCount == 1)
{
_ptlTempL1 = pos;
_ptlTempDot1 = CreateMDot(Brushes.Lime);
_measureOverlay.Children.Add(_ptlTempDot1);
SetDotPos(_ptlTempDot1, pos);
RaiseMeasureStatusChanged($"点线距 - 直线端点1: ({pos.X:F0}, {pos.Y:F0}),请点击直线端点2");
}
else if (_ptlClickCount == 2)
{
_ptlTempL2 = pos;
_ptlTempDot2 = CreateMDot(Brushes.Lime);
_measureOverlay.Children.Add(_ptlTempDot2);
SetDotPos(_ptlTempDot2, pos);
_ptlTempLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 2, IsHitTestVisible = false,
X1 = _ptlTempL1.Value.X, Y1 = _ptlTempL1.Value.Y, X2 = pos.X, Y2 = pos.Y };
_measureOverlay.Children.Add(_ptlTempLine);
RaiseMeasureStatusChanged($"点线距 - 直线已定义,请点击测量点");
}
else if (_ptlClickCount == 3)
{
// 完成:创建正式组,移除临时元素
var g = CreatePTLGroup(_ptlTempL1.Value, _ptlTempL2.Value, pos);
_ptlGroups.Add(g);
if (_ptlTempDot1 != null) _measureOverlay.Children.Remove(_ptlTempDot1);
if (_ptlTempDot2 != null) _measureOverlay.Children.Remove(_ptlTempDot2);
if (_ptlTempLine != null) _measureOverlay.Children.Remove(_ptlTempLine);
_ptlTempDot1 = _ptlTempDot2 = null; _ptlTempLine = null;
_ptlTempL1 = _ptlTempL2 = null; _ptlClickCount = 0;
var foot = g.FootPoint;
RaiseMeasureCompleted(g.P, foot, g.Distance, MeasureCount, "PointToLine");
CurrentMeasureMode = Models.MeasureMode.None;
}
}
private Models.PointToLineGroup CreatePTLGroup(Point l1, Point l2, Point p)
{
var g = new Models.PointToLineGroup { L1 = l1, L2 = l2, P = p };
g.MainLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 2, IsHitTestVisible = false };
g.ExtLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false,
StrokeDashArray = new DoubleCollection { 4, 2 }, Visibility = Visibility.Collapsed };
g.PerpLine = new Line { Stroke = Brushes.Yellow, StrokeThickness = 1, IsHitTestVisible = false,
StrokeDashArray = new DoubleCollection { 4, 2 } };
g.FootDot = new Ellipse { Width = 8, Height = 8, Fill = Brushes.Cyan, Stroke = Brushes.White, StrokeThickness = 1, IsHitTestVisible = false };
g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false };
g.DotL1 = CreateMDot(Brushes.Lime);
g.DotL2 = CreateMDot(Brushes.Lime);
g.DotP = CreateMDot(Brushes.Red);
foreach (UIElement el in new UIElement[] { g.MainLine, g.ExtLine, g.PerpLine, g.FootDot, g.Label, g.DotL1, g.DotL2, g.DotP })
_measureOverlay.Children.Add(el);
SetDotPos(g.DotL1, l1); SetDotPos(g.DotL2, l2); SetDotPos(g.DotP, p);
g.UpdateVisuals(FormatDistance(g.Distance));
return g;
}
// ── 共用:圆点创建、定位、拖拽、删除 ──
private Ellipse CreateMDot(Brush fill)
{
var dot = new Ellipse { Width = 12, Height = 12, Fill = fill, Stroke = Brushes.White, StrokeThickness = 1.5, Cursor = Cursors.Hand };
dot.SetValue(ContextMenuService.IsEnabledProperty, false);
dot.MouseLeftButtonDown += MDot_Down;
dot.MouseMove += MDot_Move;
dot.MouseLeftButtonUp += MDot_Up;
dot.PreviewMouseRightButtonUp += MDot_RightClick;
return dot;
}
private static void SetDotPos(Ellipse dot, Point pos)
{
Canvas.SetLeft(dot, pos.X - dot.Width / 2);
Canvas.SetTop(dot, pos.Y - dot.Height / 2);
}
private void MDot_Down(object sender, MouseButtonEventArgs e)
{
if (sender is not Ellipse dot) return;
// 查找点点距组
foreach (var g in _ppGroups)
{
if (g.Dot1 == dot) { _mDraggingOwner = g; _mDraggingRole = "Dot1"; break; }
if (g.Dot2 == dot) { _mDraggingOwner = g; _mDraggingRole = "Dot2"; break; }
}
// 查找点线距组
if (_mDraggingOwner == null)
{
foreach (var g in _ptlGroups)
{
if (g.DotL1 == dot) { _mDraggingOwner = g; _mDraggingRole = "DotL1"; break; }
if (g.DotL2 == dot) { _mDraggingOwner = g; _mDraggingRole = "DotL2"; break; }
if (g.DotP == dot) { _mDraggingOwner = g; _mDraggingRole = "DotP"; break; }
}
}
if (_mDraggingOwner != null) { _mDraggingDot = dot; dot.CaptureMouse(); e.Handled = true; }
}
private void MDot_Move(object sender, MouseEventArgs e)
{
if (_mDraggingDot == null || _mDraggingOwner == null || _measureOverlay == null) return;
if (e.LeftButton != MouseButtonState.Pressed) return;
var pos = e.GetPosition(_measureOverlay);
SetDotPos(_mDraggingDot, pos);
if (_mDraggingOwner is Models.MeasureGroup ppg)
{
if (_mDraggingRole == "Dot1") ppg.P1 = pos; else ppg.P2 = pos;
ppg.UpdateLine(); ppg.UpdateLabel(FormatDistance(ppg.Distance));
RaiseMeasureCompleted(ppg.P1, ppg.P2, ppg.Distance, MeasureCount, "PointDistance");
}
else if (_mDraggingOwner is Models.PointToLineGroup ptlg)
{
if (_mDraggingRole == "DotL1") ptlg.L1 = pos;
else if (_mDraggingRole == "DotL2") ptlg.L2 = pos;
else if (_mDraggingRole == "DotP") ptlg.P = pos;
ptlg.UpdateVisuals(FormatDistance(ptlg.Distance));
var foot = ptlg.FootPoint;
RaiseMeasureCompleted(ptlg.P, foot, ptlg.Distance, MeasureCount, "PointToLine");
}
e.Handled = true;
}
private void MDot_Up(object sender, MouseButtonEventArgs e)
{
if (_mDraggingDot != null) { _mDraggingDot.ReleaseMouseCapture(); _mDraggingDot = null; _mDraggingOwner = null; e.Handled = true; }
}
private void MDot_RightClick(object sender, MouseButtonEventArgs e)
{
if (sender is not Ellipse dot || _measureOverlay == null) return;
// 点点距删除
foreach (var g in _ppGroups)
{
if (g.Dot1 == dot || g.Dot2 == dot)
{
foreach (var el in new UIElement[] { g.Dot1, g.Dot2, g.Line, g.Label })
_measureOverlay.Children.Remove(el);
_ppGroups.Remove(g);
RaiseMeasureStatusChanged($"已删除测量 | 剩余 {MeasureCount} 条");
e.Handled = true; return;
}
}
// 点线距删除
foreach (var g in _ptlGroups)
{
if (g.DotL1 == dot || g.DotL2 == dot || g.DotP == dot)
{
foreach (var el in new UIElement[] { g.DotL1, g.DotL2, g.DotP, g.MainLine, g.ExtLine, g.PerpLine, g.FootDot, g.Label })
_measureOverlay.Children.Remove(el);
_ptlGroups.Remove(g);
RaiseMeasureStatusChanged($"已删除测量 | 剩余 {MeasureCount} 条");
e.Handled = true; return;
}
}
}
// ── 事件 ──
public static readonly RoutedEvent MeasureCompletedEvent =
EventManager.RegisterRoutedEvent(nameof(MeasureCompleted), RoutingStrategy.Bubble,
typeof(RoutedEventHandler), typeof(PolygonRoiCanvas));
public event RoutedEventHandler MeasureCompleted
{
add { AddHandler(MeasureCompletedEvent, value); }
remove { RemoveHandler(MeasureCompletedEvent, value); }
}
private void RaiseMeasureCompleted(Point p1, Point p2, double distance, int totalCount, string measureType)
{
RaiseEvent(new MeasureCompletedEventArgs(MeasureCompletedEvent, p1, p2, distance, totalCount) { MeasureType = measureType });
}
public static readonly RoutedEvent MeasureStatusChangedEvent =
EventManager.RegisterRoutedEvent(nameof(MeasureStatusChanged), RoutingStrategy.Bubble,
typeof(RoutedEventHandler), typeof(PolygonRoiCanvas));
public event RoutedEventHandler MeasureStatusChanged
{
add { AddHandler(MeasureStatusChangedEvent, value); }
remove { RemoveHandler(MeasureStatusChangedEvent, value); }
}
private void RaiseMeasureStatusChanged(string message)
{
RaiseEvent(new MeasureStatusEventArgs(MeasureStatusChangedEvent, message));
}
#endregion Measurement
#region Adorner Management
private void UpdateAdorner()
@@ -334,39 +758,15 @@ namespace XP.ImageProcessing.RoiControl.Controls
private void Canvas_MouseWheel(object sender, MouseWheelEventArgs e)
{
// 获取鼠标在 imageDisplayGrid 中的位置
Point mousePos = e.GetPosition(imageDisplayGrid);
// 获取鼠标在 Canvas 中的位置(缩放前)
Point mousePosOnCanvas = e.GetPosition(mainCanvas);
double oldZoom = ZoomScale;
double newZoom = oldZoom;
if (e.Delta > 0)
{
newZoom = oldZoom * ZoomStep;
}
else
{
newZoom = oldZoom / ZoomStep;
}
// 限制缩放范围
double newZoom = e.Delta > 0 ? oldZoom * ZoomStep : oldZoom / ZoomStep;
newZoom = Math.Max(0.1, Math.Min(10.0, newZoom));
if (Math.Abs(newZoom - oldZoom) > 0.001)
{
// 计算缩放比例变化
double scale = newZoom / oldZoom;
// 更新缩放
ZoomScale = newZoom;
// 调整平移偏移,使鼠标位置保持不变
// 新的偏移 = 旧偏移 + 鼠标位置 - 鼠标位置 * 缩放比例
PanOffsetX = mousePos.X - (mousePos.X - PanOffsetX) * scale;
PanOffsetY = mousePos.Y - (mousePos.Y - PanOffsetY) * scale;
// RenderTransformOrigin="0.5,0.5" 保证以图像中心等比缩放
// 拖拽平移偏移保持不变
}
e.Handled = true;
@@ -402,8 +802,9 @@ namespace XP.ImageProcessing.RoiControl.Controls
if (!isDragging)
{
// 处理点击事件
Point clickPosition = e.GetPosition(mainCanvas);
if (IsMeasuring)
HandleMeasureClick(clickPosition);
OnCanvasClicked(clickPosition);
}
@@ -414,7 +815,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
{
// 右键点击完成多边形
OnRightClick();
e.Handled = true;
// 不设 e.Handled,让 ContextMenu 正常弹出
}
private void ROI_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
@@ -433,35 +834,33 @@ namespace XP.ImageProcessing.RoiControl.Controls
public void ResetView()
{
// 自动适应显示窗口 (类似 PictureBox SizeMode.Zoom)
ZoomScale = 1.0;
PanOffsetX = 0;
PanOffsetY = 0;
if (imageDisplayGrid != null && CanvasWidth > 0 && CanvasHeight > 0)
if (imageDisplayGrid == null || CanvasWidth <= 0 || CanvasHeight <= 0)
{
// 使用 Dispatcher 延迟执行,确保布局已完成
Dispatcher.BeginInvoke(new Action(() =>
{
// 获取图像显示区域的实际尺寸
double viewportWidth = imageDisplayGrid.ActualWidth;
double viewportHeight = imageDisplayGrid.ActualHeight;
if (viewportWidth > 0 && viewportHeight > 0)
{
// 计算宽度和高度的缩放比例
double scaleX = viewportWidth / CanvasWidth;
double scaleY = viewportHeight / CanvasHeight;
// 选择较小的缩放比例,确保图像完全显示在窗口内(保持宽高比)
ZoomScale = Math.Min(scaleX, scaleY);
// 居中显示由 Grid 的 HorizontalAlignment 和 VerticalAlignment 自动处理
PanOffsetX = 0;
PanOffsetY = 0;
}
}), System.Windows.Threading.DispatcherPriority.Loaded);
ZoomScale = 1.0;
return;
}
// 延迟到布局完成后计算,确保 ActualWidth/Height 准确
Dispatcher.BeginInvoke(new Action(() =>
{
double viewW = imageDisplayGrid.ActualWidth;
double viewH = imageDisplayGrid.ActualHeight;
if (viewW > 0 && viewH > 0)
{
ZoomScale = Math.Min(viewW / CanvasWidth, viewH / CanvasHeight);
}
else
{
ZoomScale = 1.0;
}
PanOffsetX = 0;
PanOffsetY = 0;
}), System.Windows.Threading.DispatcherPriority.Render);
}
private void BtnZoomIn_Click(object sender, RoutedEventArgs e)
@@ -470,6 +869,8 @@ namespace XP.ImageProcessing.RoiControl.Controls
if (newZoom <= 10.0)
{
ZoomScale = newZoom;
PanOffsetX = 0;
PanOffsetY = 0;
}
}
@@ -479,6 +880,8 @@ namespace XP.ImageProcessing.RoiControl.Controls
if (newZoom >= 0.1)
{
ZoomScale = newZoom;
PanOffsetX = 0;
PanOffsetY = 0;
}
}
@@ -0,0 +1,43 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
namespace XP.ImageProcessing.RoiControl.Models
{
/// <summary>一次点点距测量的所有视觉元素</summary>
public class MeasureGroup
{
public Ellipse Dot1 { get; set; }
public Ellipse Dot2 { get; set; }
public Line Line { get; set; }
public TextBlock Label { get; set; }
public Point P1 { get; set; }
public Point P2 { get; set; }
public double Distance
{
get
{
double dx = P2.X - P1.X, dy = P2.Y - P1.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
}
public void UpdateLine()
{
Line.X1 = P1.X; Line.Y1 = P1.Y;
Line.X2 = P2.X; Line.Y2 = P2.Y;
Line.Visibility = Visibility.Visible;
}
public void UpdateLabel(string distanceText = null)
{
Label.Text = distanceText ?? $"{Distance:F2} px";
Canvas.SetLeft(Label, (P1.X + P2.X) / 2 + 8);
Canvas.SetTop(Label, (P1.Y + P2.Y) / 2 - 18);
Label.Visibility = Visibility.Visible;
}
}
}
@@ -0,0 +1,9 @@
namespace XP.ImageProcessing.RoiControl.Models
{
public enum MeasureMode
{
None,
PointDistance,
PointToLine
}
}
@@ -0,0 +1,96 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
namespace XP.ImageProcessing.RoiControl.Models
{
/// <summary>一次点线距测量的所有视觉元素(直线两端点 + 测量点 + 垂线 + 标签)</summary>
public class PointToLineGroup
{
public Ellipse DotL1 { get; set; } // 直线端点1
public Ellipse DotL2 { get; set; } // 直线端点2
public Ellipse DotP { get; set; } // 测量点
public Line MainLine { get; set; } // 原始线段(实线)
public Line ExtLine { get; set; } // 延长线(虚线)
public Line PerpLine { get; set; } // 垂线(测量点→垂足)
public Ellipse FootDot { get; set; } // 垂足
public TextBlock Label { get; set; }
public Point L1 { get; set; }
public Point L2 { get; set; }
public Point P { get; set; }
public double Distance
{
get
{
double abx = L2.X - L1.X, aby = L2.Y - L1.Y;
double abLen = Math.Sqrt(abx * abx + aby * aby);
if (abLen < 0.001) return 0;
return Math.Abs(abx * (L1.Y - P.Y) - aby * (L1.X - P.X)) / abLen;
}
}
public Point FootPoint
{
get
{
double abx = L2.X - L1.X, aby = L2.Y - L1.Y;
double abLen2 = abx * abx + aby * aby;
if (abLen2 < 0.001) return L1;
double t = ((P.X - L1.X) * abx + (P.Y - L1.Y) * aby) / abLen2;
return new Point(L1.X + t * abx, L1.Y + t * aby);
}
}
public void UpdateVisuals(string distanceText)
{
double abx = L2.X - L1.X, aby = L2.Y - L1.Y;
double abLen2 = abx * abx + aby * aby;
double t = abLen2 < 0.001 ? 0 : ((P.X - L1.X) * abx + (P.Y - L1.Y) * aby) / abLen2;
var foot = FootPoint;
// 主直线:始终画原始线段
MainLine.X1 = L1.X; MainLine.Y1 = L1.Y;
MainLine.X2 = L2.X; MainLine.Y2 = L2.Y;
MainLine.Visibility = Visibility.Visible;
// 延长线:垂足在线段外时画虚线延伸
if (t < 0)
{
ExtLine.X1 = foot.X; ExtLine.Y1 = foot.Y;
ExtLine.X2 = L1.X; ExtLine.Y2 = L1.Y;
ExtLine.Visibility = Visibility.Visible;
}
else if (t > 1)
{
ExtLine.X1 = L2.X; ExtLine.Y1 = L2.Y;
ExtLine.X2 = foot.X; ExtLine.Y2 = foot.Y;
ExtLine.Visibility = Visibility.Visible;
}
else
{
ExtLine.Visibility = Visibility.Collapsed;
}
// 垂线
PerpLine.X1 = P.X; PerpLine.Y1 = P.Y;
PerpLine.X2 = foot.X; PerpLine.Y2 = foot.Y;
PerpLine.Visibility = Visibility.Visible;
// 垂足
Canvas.SetLeft(FootDot, foot.X - FootDot.Width / 2);
Canvas.SetTop(FootDot, foot.Y - FootDot.Height / 2);
FootDot.Visibility = Visibility.Visible;
// 标签
Label.Text = distanceText ?? $"{Distance:F2} px";
Canvas.SetLeft(Label, (P.X + foot.X) / 2 + 8);
Canvas.SetTop(Label, (P.Y + foot.Y) / 2 - 18);
Label.Visibility = Visibility.Visible;
}
public void UpdateVisuals() => UpdateVisuals(null);
}
}
+17
View File
@@ -64,6 +64,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Scan", "XP.Scan\XP.Scan.
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "XP.ScanMode", "XP.ScanMode", "{E208A5EA-7E3B-46B4-B045-A703F6274218}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Calibration", "XP.Calibration\XP.Calibration.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "XP.Calibration", "XP.Calibration", "{D4E5F6A7-B8C9-0123-4567-89ABCDEF0123}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -266,6 +270,18 @@ Global
{F40C71DC-7639-CD57-6183-2EAA78980EC5}.Release|x64.Build.0 = Release|Any CPU
{F40C71DC-7639-CD57-6183-2EAA78980EC5}.Release|x86.ActiveCfg = Release|Any CPU
{F40C71DC-7639-CD57-6183-2EAA78980EC5}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -284,6 +300,7 @@ Global
{B8F5E3A1-7C2D-4E9F-A1B3-6D8E4F2C9A01} = {29E2D405-341A-4445-B788-3E77A677C2BA}
{6170AF9F-A792-6BDC-4E25-072EA87FAA15} = {29E2D405-341A-4445-B788-3E77A677C2BA}
{F40C71DC-7639-CD57-6183-2EAA78980EC5} = {E208A5EA-7E3B-46B4-B045-A703F6274218}
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {D4E5F6A7-B8C9-0123-4567-89ABCDEF0123}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DB6D69BA-49FD-432F-8069-2A8F64933CDE}
+15 -15
View File
@@ -265,23 +265,23 @@ namespace XplorePlane
// 主窗口加载完成后再连接相机,确保所有模块和原生 DLL 已完成初始化
shell.Loaded += (s, e) =>
{
TryConnectCamera();
//TryConnectCamera();
// 通知 ViewModel 相机状态已确定,启动实时预览或显示错误
try
{
var cameraVm = Container.Resolve<NavigationPropertyPanelViewModel>();
cameraVm.OnCameraReady();
}
catch (Exception ex)
{
Log.Error(ex, "通知相机 ViewModel 失败");
}
//// 通知 ViewModel 相机状态已确定,启动实时预览或显示错误
// try
// {
// var cameraVm = Container.Resolve<NavigationPropertyPanelViewModel>();
// cameraVm.OnCameraReady();
// }
// catch (Exception ex)
// {
// Log.Error(ex, "通知相机 ViewModel 失败");
// }
if (_cameraError != null)
{
HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
}
// if (_cameraError != null)
// {
// HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
//}
};
return shell;
+505
View File
@@ -0,0 +1,505 @@
# CNC 多检测结果归档与报告取数说明
## 1. 目标
为 CNC 执行结果提供一套适合报告模块直接取数的归档结构。
设计目标:
- 以“一次工件检测实例”作为主归档单位
- 同时保留到“检测节点级别”的明细
- 支持保存:
- CNC 程序名
- 工件号 / 序列号
- 检测节点信息
- 节点使用的 Pipeline / 配方快照
- 原图、节点输入图、节点最终结果图
- 节点输出的多个数值结果
- 节点判定和整次实例判定
- 方便后续报告模块直接读取,不依赖运行时最新配方
当前实现采用:
- SQLite 保存结构化索引数据
- 文件系统保存图片资产和 `manifest.json`
---
## 2. 总体设计
一次检测实例会生成:
1. 一组数据库记录
2. 一组文件目录和图像文件
3. 一份 `manifest.json` 快照文件
归档核心对象:
- `InspectionRunRecord`
- 一次完整检测实例
- `InspectionNodeResult`
- 一个 CNC 检测节点的结果
- `InspectionMetricResult`
- 节点输出的数值结果
- `InspectionAssetRecord`
- 图像或附件索引
- `PipelineExecutionSnapshot`
- 节点执行时使用的 Pipeline 快照
默认图片保留策略:
- 整次实例原图
- 每个节点的输入图
- 每个节点的最终结果图
不默认保存每个算法步骤的中间图。
---
## 3. 文件存储结构
### 3.1 根目录
默认根目录:
```text
%AppData%\XplorePlane\InspectionResults
```
每次检测实例按日期和 `RunId` 分层:
```text
Results/{yyyy}/{MM}/{dd}/{RunId}/
```
### 3.2 示例目录
假设:
- `ProgramName = NewCncProgram`
- `WorkpieceId = QFN_1`
- `SerialNumber = SN-001`
- `RunId = 7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a`
- 检测节点共 2 个
则文件结构大致为:
```text
%AppData%\XplorePlane\InspectionResults\
└─ Results\
└─ 2026\
└─ 04\
└─ 21\
└─ 7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a\
├─ manifest.json
├─ run\
│ └─ source.bmp
└─ nodes\
├─ 001_检测节点1\
│ ├─ input.bmp
│ └─ result_overlay.bmp
└─ 002_检测节点2\
└─ result_overlay.bmp
```
### 3.3 文件说明
- `run/source.bmp`
- 本次工件检测实例的原始输入图
- `nodes/001_检测节点1/input.bmp`
- 节点 1 输入图
- `nodes/001_检测节点1/result_overlay.bmp`
- 节点 1 最终结果图
- `nodes/002_检测节点2/result_overlay.bmp`
- 节点 2 最终结果图
- `manifest.json`
- 本次检测完整快照,便于离线查看、调试和导出
---
## 4. 数据库表设计
当前实现包含 5 张主表。
### 4.1 `inspection_runs`
用途:保存一次完整检测实例的主记录。
| 字段 | 类型 | 说明 |
|---|---|---|
| `run_id` | `TEXT` | 主键,检测实例 IDGUID |
| `program_name` | `TEXT` | CNC 程序名 |
| `workpiece_id` | `TEXT` | 工件号 |
| `serial_number` | `TEXT` | 序列号 |
| `started_at` | `TEXT` | 开始时间,ISO 8601 |
| `completed_at` | `TEXT` | 结束时间,ISO 8601,可空 |
| `overall_pass` | `INTEGER` | 整体判定,`0/1` |
| `source_image_path` | `TEXT` | 原图相对路径 |
| `result_root_path` | `TEXT` | 本次结果包根目录相对路径 |
| `node_count` | `INTEGER` | 节点数量 |
样例数据:
| run_id | program_name | workpiece_id | serial_number | started_at | completed_at | overall_pass | source_image_path | result_root_path | node_count |
|---|---|---|---|---|---|---:|---|---|---:|
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `NewCncProgram` | `QFN_1` | `SN-001` | `2026-04-21T10:00:00.0000000Z` | `2026-04-21T10:00:03.2000000Z` | `0` | `Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/run/source.bmp` | `Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `2` |
### 4.2 `inspection_node_results`
用途:保存一次检测实例中的节点级结果。
| 字段 | 类型 | 说明 |
|---|---|---|
| `run_id` | `TEXT` | 所属检测实例 ID |
| `node_id` | `TEXT` | 节点 IDGUID |
| `node_index` | `INTEGER` | 节点序号 |
| `node_name` | `TEXT` | 节点名称 |
| `pipeline_id` | `TEXT` | Pipeline IDGUID |
| `pipeline_name` | `TEXT` | Pipeline 名称 |
| `pipeline_version_hash` | `TEXT` | Pipeline 快照 hash |
| `node_pass` | `INTEGER` | 节点判定,`0/1` |
| `source_image_path` | `TEXT` | 节点输入图相对路径 |
| `result_image_path` | `TEXT` | 节点结果图相对路径 |
| `status` | `TEXT` | 节点状态:`Succeeded / Failed / PartialSuccess / AssetMissing` |
| `duration_ms` | `INTEGER` | 节点耗时 |
样例数据:
| run_id | node_id | node_index | node_name | pipeline_name | pipeline_version_hash | node_pass | source_image_path | result_image_path | status | duration_ms |
|---|---|---:|---|---|---|---:|---|---|---|---:|
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `1` | `检测节点1` | `Recipe-A` | `A1B2C3...` | `1` | `Results/2026/04/21/.../nodes/001_检测节点1/input.bmp` | `Results/2026/04/21/.../nodes/001_检测节点1/result_overlay.bmp` | `Succeeded` | `135` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `2` | `检测节点2` | `Recipe-B` | `D4E5F6...` | `0` | `` | `Results/2026/04/21/.../nodes/002_检测节点2/result_overlay.bmp` | `Failed` | `240` |
### 4.3 `inspection_metric_results`
用途:保存节点输出的结构化数值结果。
| 字段 | 类型 | 说明 |
|---|---|---|
| `run_id` | `TEXT` | 所属检测实例 ID |
| `node_id` | `TEXT` | 所属节点 ID |
| `metric_key` | `TEXT` | 指标 key |
| `metric_name` | `TEXT` | 指标名称 |
| `metric_value` | `REAL` | 指标值 |
| `unit` | `TEXT` | 单位 |
| `lower_limit` | `REAL` | 下限,可空 |
| `upper_limit` | `REAL` | 上限,可空 |
| `is_pass` | `INTEGER` | 单指标判定,`0/1` |
| `display_order` | `INTEGER` | 展示顺序 |
样例数据:
| run_id | node_id | metric_key | metric_name | metric_value | unit | lower_limit | upper_limit | is_pass | display_order |
|---|---|---|---|---:|---|---:|---:|---:|---:|
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `bridge.rate` | `Bridge Rate` | `0.12` | `%` | | `0.2` | `1` | `1` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `void.area` | `Void Area` | `5.6` | `px` | | `8` | `1` | `2` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `solder.height` | `Solder Height` | `1.7` | `mm` | `1.8` | | `0` | `1` |
### 4.4 `inspection_assets`
用途:保存文件资产索引。
| 字段 | 类型 | 说明 |
|---|---|---|
| `run_id` | `TEXT` | 所属检测实例 ID |
| `node_id` | `TEXT` | 所属节点 ID,可空 |
| `asset_type` | `TEXT` | 资产类型 |
| `relative_path` | `TEXT` | 相对路径 |
| `file_format` | `TEXT` | 文件格式 |
| `width` | `INTEGER` | 宽度 |
| `height` | `INTEGER` | 高度 |
约定的 `asset_type`
- `RunSourceImage`
- `NodeInputImage`
- `NodeResultImage`
样例数据:
| run_id | node_id | asset_type | relative_path | file_format | width | height |
|---|---|---|---|---|---:|---:|
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | | `RunSourceImage` | `Results/2026/04/21/.../run/source.bmp` | `bmp` | `0` | `0` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `NodeInputImage` | `Results/2026/04/21/.../nodes/001_检测节点1/input.bmp` | `bmp` | `0` | `0` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `NodeResultImage` | `Results/2026/04/21/.../nodes/001_检测节点1/result_overlay.bmp` | `bmp` | `0` | `0` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `NodeResultImage` | `Results/2026/04/21/.../nodes/002_检测节点2/result_overlay.bmp` | `bmp` | `0` | `0` |
### 4.5 `pipeline_execution_snapshots`
用途:保存节点执行时的 Pipeline 快照,避免后续配方修改影响历史报告。
| 字段 | 类型 | 说明 |
|---|---|---|
| `run_id` | `TEXT` | 所属检测实例 ID |
| `node_id` | `TEXT` | 所属节点 ID |
| `pipeline_name` | `TEXT` | Pipeline 名称 |
| `pipeline_definition_json` | `TEXT` | Pipeline 序列化 JSON |
| `pipeline_hash` | `TEXT` | Pipeline JSON 的 SHA-256 |
样例数据:
| run_id | node_id | pipeline_name | pipeline_hash |
|---|---|---|---|
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `Recipe-A` | `A1B2C3...` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `Recipe-B` | `D4E5F6...` |
---
## 5. `manifest.json` 示例
每次 `CompleteRunAsync` 后,会在结果包目录下生成 `manifest.json`。
示例:
```json
{
"Run": {
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"ProgramName": "NewCncProgram",
"WorkpieceId": "QFN_1",
"SerialNumber": "SN-001",
"StartedAt": "2026-04-21T10:00:00.0000000Z",
"CompletedAt": "2026-04-21T10:00:03.2000000Z",
"OverallPass": false,
"SourceImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/run/source.bmp",
"ResultRootPath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeCount": 2
},
"Nodes": [
{
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeId": "11111111-1111-1111-1111-111111111111",
"NodeIndex": 1,
"NodeName": "检测节点1",
"PipelineName": "Recipe-A",
"PipelineVersionHash": "A1B2C3...",
"NodePass": true,
"SourceImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/nodes/001_检测节点1/input.bmp",
"ResultImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/nodes/001_检测节点1/result_overlay.bmp",
"Status": 0,
"DurationMs": 135
},
{
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeId": "22222222-2222-2222-2222-222222222222",
"NodeIndex": 2,
"NodeName": "检测节点2",
"PipelineName": "Recipe-B",
"PipelineVersionHash": "D4E5F6...",
"NodePass": false,
"SourceImagePath": "",
"ResultImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/nodes/002_检测节点2/result_overlay.bmp",
"Status": 1,
"DurationMs": 240
}
],
"Metrics": [
{
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeId": "11111111-1111-1111-1111-111111111111",
"MetricKey": "bridge.rate",
"MetricName": "Bridge Rate",
"MetricValue": 0.12,
"Unit": "%",
"LowerLimit": null,
"UpperLimit": 0.2,
"IsPass": true,
"DisplayOrder": 1
},
{
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeId": "22222222-2222-2222-2222-222222222222",
"MetricKey": "solder.height",
"MetricName": "Solder Height",
"MetricValue": 1.7,
"Unit": "mm",
"LowerLimit": 1.8,
"UpperLimit": null,
"IsPass": false,
"DisplayOrder": 1
}
]
}
```
说明:
- `manifest.json` 是文件侧的完整快照
- SQLite 是主索引
- 报告模块可以优先查库,再按需读取文件
---
## 6. 报告模块如何取数
### 6.1 首页列表 / 历史查询
建议通过:
- `inspection_runs`
- 必要时联查 `inspection_node_results`
可支持筛选条件:
- 时间范围
- 程序名
- 工件号
- 序列号
- Pipeline 名称
### 6.2 单份报告生成
建议按 `RunId` 调用:
- `GetRunDetailAsync(runId)`
得到:
- `Run`
- `Nodes`
- `Metrics`
- `Assets`
- `PipelineSnapshots`
即可直接组装报告:
- 报告首页
- 工件号、序列号、程序名、开始/结束时间、整体判定
- 节点章节
- 节点名称
- 配方名
- 结果图
- 关键指标值
- 节点判定
- 追溯信息
- Pipeline 快照 hash
- 原图路径
- 结果图路径
### 6.3 离线导出
若后续需要将单个检测实例导出给第三方或留档,可直接复制:
- 整个 `Results/{yyyy}/{MM}/{dd}/{RunId}` 目录
这样会同时带走:
- `manifest.json`
- 原图
- 节点图
---
## 7. 当前实现接口
当前服务接口:
- `BeginRunAsync(...)`
- 创建实例记录和结果目录
- `AppendNodeResultAsync(...)`
- 写入节点结果、指标、图片索引、Pipeline 快照
- `CompleteRunAsync(...)`
- 回填结束时间、整体判定,并写出 `manifest.json`
- `QueryRunsAsync(...)`
- 查询检测实例列表
- `GetRunDetailAsync(...)`
- 查询单个实例的完整报告数据
当前 DI 注册:
- `IInspectionResultStore -> InspectionResultStore`
---
## 8. 设计约束与说明
### 8.1 为什么不直接扩展 `MeasurementDataService`
因为原有 `MeasurementRecord` 只适合:
- 单值统计
- 简单的 pass/fail 汇总
它不适合承载:
- 多节点
- 多图像
- 多指标
- Pipeline 快照
- 报告导出
所以当前设计中:
- `MeasurementDataService` 继续保留给旧统计用途
- `InspectionResultStore` 作为报告归档主通道
### 8.2 为什么图片不直接进 SQLite
因为图片数据量大,直接入库会带来:
- 数据库膨胀
- 查询性能下降
- 迁移和备份成本变高
因此使用:
- SQLite 存索引
- 文件系统存图片
这是更适合报告场景的折中方案。
### 8.3 为什么要保存 Pipeline 快照
因为报告要可追溯。
如果只保存 `PipelineName`,后续配方被修改后,历史报告就无法复原当时的真实算法链。
因此需要保存:
- `pipeline_definition_json`
- `pipeline_hash`
---
## 9. 后续可扩展方向
后续可继续扩展:
1. 增加 `manifest.json` 中的设备上下文
- 运动位置
- 射线源状态
- 探测器状态
2. 增加中间图可选保留策略
- 默认关闭
- 调试模式开启
3. 增加结果导出包
- ZIP 打包
- 单份报告 PDF
4. 增加报告模板字段映射
- 将 `MetricKey` 映射到报告模板占位符
5. 增加数据清理策略
- 保留天数
- 自动清理旧图片
- 保留数据库索引或同时删除
---
## 10. 结论
当前这套归档设计的核心特点是:
- 以“检测实例”为主组织数据
- 以“检测节点”为明细展开
- 以“结构化指标 + 图片文件 + Pipeline 快照”支撑报告
- 通过 SQLite 和文件系统混合存储兼顾查询效率和图片落盘
对于后续报告模块,这套结构已经可以直接支持:
- 历史列表查询
- 单次检测报告生成
- 结果图展示
- 节点级指标展示
- 历史结果可追溯
@@ -0,0 +1,26 @@
using Prism.Events;
namespace XplorePlane.Events
{
/// <summary>
/// 测量工具模式
/// </summary>
public enum MeasurementToolMode
{
None,
PointDistance,
PointLineDistance,
Angle,
ThroughHoleFillRate
}
/// <summary>
/// 测量工具激活事件,由 MainViewModel 发布,ViewportPanelViewModel 订阅
/// </summary>
public class MeasurementToolEvent : PubSubEvent<MeasurementToolMode> { }
/// <summary>
/// 十字辅助线切换事件
/// </summary>
public class ToggleCrosshairEvent : PubSubEvent { }
}
+2 -2
View File
@@ -3,7 +3,7 @@ using System.Collections.Generic;
namespace XplorePlane.Models
{
public class PipelineModel
public class PipelineModel //流程图
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
@@ -13,7 +13,7 @@ namespace XplorePlane.Models
public List<PipelineNodeModel> Nodes { get; set; } = new();
}
public class PipelineNodeModel
public class PipelineNodeModel //节点
{
public Guid Id { get; set; } = Guid.NewGuid();
public string OperatorKey { get; set; } = string.Empty;
+13 -2
View File
@@ -364,9 +364,20 @@ namespace XplorePlane.Services.Cnc
private static IReadOnlyList<CncNode> RenumberNodes(List<CncNode> nodes)
{
var result = new List<CncNode>(nodes.Count);
int referencePointNumber = 0;
int savePositionNumber = 0;
int inspectionModuleNumber = 0;
for (int i = 0; i < nodes.Count; i++)
{
result.Add(nodes[i] with { Index = i });
var indexedNode = nodes[i] with { Index = i };
result.Add(indexedNode switch
{
ReferencePointNode referencePointNode => referencePointNode with { Name = $"\u53C2\u8003\u70B9_{referencePointNumber++}" },
SavePositionNode savePositionNode => savePositionNode with { Name = $"\u4FDD\u5B58\u4F4D\u7F6E_{savePositionNumber++}" },
InspectionModuleNode inspectionModuleNode => inspectionModuleNode with { Name = $"\u68C0\u6D4B\u6A21\u5757_{inspectionModuleNumber++}" },
_ => indexedNode
});
}
return result.AsReadOnly();
}
@@ -414,7 +425,7 @@ namespace XplorePlane.Services.Cnc
private SavePositionNode CreateSavePositionNode(Guid id, int index)
{
return new SavePositionNode(
id, index, $"保存位置_{index}",
id, index, $"检测位置_{index}",
MotionState: _appStateService.MotionState);
}
private double TryReadCurrent()
@@ -37,11 +37,10 @@ namespace XplorePlane.Services
if (enabledNodes.Count == 0)
return source;
// 大图像预览缩放
var current = ScaleForPreview(source);
int total = enabledNodes.Count;
for (int step = 0; step < total; step++)
var total = enabledNodes.Count;
for (var step = 0; step < total; step++)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -53,15 +52,14 @@ namespace XplorePlane.Services
if (invalidParameters.Count > 0)
{
var invalidParameterText = string.Join("、", invalidParameters);
throw new PipelineExecutionException(
$"算子 '{node.DisplayName}' 存在无效参数:{string.Join("", invalidParameters)}",
$"算子 '{node.DisplayName}' 存在无效参数:{invalidParameterText}",
node.Order,
node.OperatorKey);
}
var parameters = node.Parameters
.Where(p => p.IsValueValid)
.ToDictionary(p => p.Name, p => p.Value);
var parameters = node.Parameters.ToDictionary(p => p.Name, p => p.Value);
try
{
@@ -69,9 +67,12 @@ namespace XplorePlane.Services
current, node.OperatorKey, parameters, null, cancellationToken);
if (current == null)
{
throw new PipelineExecutionException(
$"算子 '{node.OperatorKey}' 返回了空图像",
node.Order, node.OperatorKey);
$"算子 '{node.DisplayName}' 返回了空图像",
node.Order,
node.OperatorKey);
}
}
catch (OperationCanceledException)
{
@@ -85,7 +86,9 @@ namespace XplorePlane.Services
{
throw new PipelineExecutionException(
$"算子 '{node.DisplayName}' 执行失败:{ex.Message}",
node.Order, node.OperatorKey, ex);
node.Order,
node.OperatorKey,
ex);
}
progress?.Report(new PipelineProgress(step + 1, total, node.DisplayName));
@@ -102,7 +105,7 @@ namespace XplorePlane.Services
if (source.PixelWidth <= UhdThreshold && source.PixelHeight <= UhdThreshold)
return source;
double scale = (double)PreviewMaxHeight / source.PixelHeight;
var scale = (double)PreviewMaxHeight / source.PixelHeight;
if (source.PixelWidth * scale > UhdThreshold)
scale = (double)UhdThreshold / source.PixelWidth;
@@ -74,7 +74,7 @@ namespace XplorePlane.Services
if (!Directory.Exists(directory))
return Array.Empty<PipelineModel>();
var files = Directory.GetFiles(directory, "*.imw");
var files = Directory.GetFiles(directory, "*.xpm");
var results = new List<PipelineModel>();
foreach (var file in files)
@@ -108,4 +108,4 @@ namespace XplorePlane.Services
throw new UnauthorizedAccessException($"不允许路径遍历:{directory}");
}
}
}
}
@@ -30,10 +30,14 @@ namespace XplorePlane.ViewModels.Cnc
private CncProgram _currentProgram;
private ObservableCollection<CncNodeViewModel> _nodes;
private ObservableCollection<CncNodeViewModel> _treeNodes;
private ObservableCollection<CncProgramTreeRootViewModel> _programTreeRoots;
private CncNodeViewModel _selectedNode;
private bool _isModified;
private string _programName;
private string _programDisplayName = "新建检测程序.xp";
private Guid? _preferredSelectedNodeId;
private Guid? _pendingInsertAnchorNodeId;
private bool _pendingInsertAfterAnchor;
public CncEditorViewModel(
ICncProgramService cncProgramService,
@@ -48,6 +52,10 @@ namespace XplorePlane.ViewModels.Cnc
_nodes = new ObservableCollection<CncNodeViewModel>();
_treeNodes = new ObservableCollection<CncNodeViewModel>();
_programTreeRoots = new ObservableCollection<CncProgramTreeRootViewModel>
{
new CncProgramTreeRootViewModel(_programDisplayName, _treeNodes)
};
InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint));
InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage));
@@ -63,6 +71,8 @@ namespace XplorePlane.ViewModels.Cnc
.ObservesProperty(() => SelectedNode);
MoveNodeUpCommand = new DelegateCommand<CncNodeViewModel>(ExecuteMoveNodeUp);
MoveNodeDownCommand = new DelegateCommand<CncNodeViewModel>(ExecuteMoveNodeDown);
PrepareInsertAboveCommand = new DelegateCommand<CncNodeViewModel>(nodeVm => SetPendingInsertAnchor(nodeVm, insertAfter: false));
PrepareInsertBelowCommand = new DelegateCommand<CncNodeViewModel>(nodeVm => SetPendingInsertAnchor(nodeVm, insertAfter: true));
SaveProgramCommand = new DelegateCommand(async () => await ExecuteSaveProgramAsync());
LoadProgramCommand = new DelegateCommand(async () => await ExecuteLoadProgramAsync());
@@ -84,6 +94,12 @@ namespace XplorePlane.ViewModels.Cnc
private set => SetProperty(ref _treeNodes, value);
}
public ObservableCollection<CncProgramTreeRootViewModel> ProgramTreeRoots
{
get => _programTreeRoots;
private set => SetProperty(ref _programTreeRoots, value);
}
public CncNodeViewModel SelectedNode
{
get => _selectedNode;
@@ -110,6 +126,16 @@ namespace XplorePlane.ViewModels.Cnc
set => SetProperty(ref _programName, value);
}
public string ProgramDisplayName
{
get => _programDisplayName;
private set
{
if (SetProperty(ref _programDisplayName, value) && ProgramTreeRoots?.Count > 0)
ProgramTreeRoots[0].DisplayName = value;
}
}
public DelegateCommand InsertReferencePointCommand { get; }
public DelegateCommand InsertSaveNodeWithImageCommand { get; }
public DelegateCommand InsertSaveNodeCommand { get; }
@@ -122,6 +148,8 @@ namespace XplorePlane.ViewModels.Cnc
public DelegateCommand DeleteNodeCommand { get; }
public DelegateCommand<CncNodeViewModel> MoveNodeUpCommand { get; }
public DelegateCommand<CncNodeViewModel> MoveNodeDownCommand { get; }
public DelegateCommand<CncNodeViewModel> PrepareInsertAboveCommand { get; }
public DelegateCommand<CncNodeViewModel> PrepareInsertBelowCommand { get; }
public DelegateCommand SaveProgramCommand { get; }
public DelegateCommand LoadProgramCommand { get; }
public DelegateCommand NewProgramCommand { get; }
@@ -140,6 +168,7 @@ namespace XplorePlane.ViewModels.Cnc
int afterIndex = ResolveInsertAfterIndex(nodeType);
_currentProgram = _cncProgramService.InsertNode(_currentProgram, afterIndex, node);
_preferredSelectedNodeId = node.Id;
ClearPendingInsertAnchor();
OnProgramEdited();
_logger.Info("Inserted node: Type={NodeType}", nodeType);
@@ -157,21 +186,24 @@ namespace XplorePlane.ViewModels.Cnc
try
{
int deletedIndex = SelectedNode.Index;
if (SelectedNode.IsSavePosition)
{
var nodes = _currentProgram.Nodes.ToList();
int startIndex = SelectedNode.Index;
int startIndex = deletedIndex;
int endIndex = GetSavePositionBlockEndIndex(startIndex);
nodes.RemoveRange(startIndex, endIndex - startIndex + 1);
_currentProgram = ReplaceProgramNodes(nodes);
}
else
{
_currentProgram = _cncProgramService.RemoveNode(_currentProgram, SelectedNode.Index);
_currentProgram = _cncProgramService.RemoveNode(_currentProgram, deletedIndex);
}
OnProgramEdited();
_logger.Info("Deleted node at index: {Index}", SelectedNode.Index);
ClearPendingInsertAnchorIfMissing();
_logger.Info("Deleted node at index: {Index}", deletedIndex);
}
catch (ArgumentOutOfRangeException ex)
{
@@ -256,6 +288,7 @@ namespace XplorePlane.ViewModels.Cnc
return;
await _cncProgramService.SaveAsync(_currentProgram, dlg.FileName);
ProgramDisplayName = Path.GetFileName(dlg.FileName);
IsModified = false;
}
catch (Exception ex)
@@ -280,7 +313,9 @@ namespace XplorePlane.ViewModels.Cnc
_currentProgram = await _cncProgramService.LoadAsync(dlg.FileName);
ProgramName = _currentProgram.Name;
ProgramDisplayName = Path.GetFileName(dlg.FileName);
IsModified = false;
ClearPendingInsertAnchor();
RefreshNodes();
}
catch (Exception ex)
@@ -291,10 +326,12 @@ namespace XplorePlane.ViewModels.Cnc
private void ExecuteNewProgram()
{
var name = string.IsNullOrWhiteSpace(ProgramName) ? "NewCncProgram" : ProgramName;
const string name = "新建检测程序";
_currentProgram = _cncProgramService.CreateProgram(name);
ProgramName = _currentProgram.Name;
ProgramDisplayName = FormatProgramDisplayName(_currentProgram.Name);
IsModified = false;
ClearPendingInsertAnchor();
RefreshNodes();
}
@@ -379,6 +416,8 @@ namespace XplorePlane.ViewModels.Cnc
private void RefreshNodes()
{
NormalizeDefaultNodeNamesInCurrentProgram();
var selectedId = _preferredSelectedNodeId ?? SelectedNode?.Id;
var expansionState = Nodes.ToDictionary(node => node.Id, node => node.IsExpanded);
@@ -417,6 +456,10 @@ namespace XplorePlane.ViewModels.Cnc
Nodes = new ObservableCollection<CncNodeViewModel>(flatNodes);
TreeNodes = new ObservableCollection<CncNodeViewModel>(rootNodes);
ProgramTreeRoots = new ObservableCollection<CncProgramTreeRootViewModel>
{
new CncProgramTreeRootViewModel(ProgramDisplayName, TreeNodes)
};
SelectedNode = selectedId.HasValue
? Nodes.FirstOrDefault(node => node.Id == selectedId.Value) ?? Nodes.LastOrDefault()
@@ -425,6 +468,31 @@ namespace XplorePlane.ViewModels.Cnc
_preferredSelectedNodeId = null;
}
private void NormalizeDefaultNodeNamesInCurrentProgram()
{
if (_currentProgram?.Nodes == null || _currentProgram.Nodes.Count == 0)
{
return;
}
var normalizedNodes = ApplyDefaultNodeNames(_currentProgram.Nodes);
bool changed = false;
for (int i = 0; i < normalizedNodes.Count; i++)
{
if (!Equals(normalizedNodes[i], _currentProgram.Nodes[i]))
{
changed = true;
break;
}
}
if (changed)
{
_currentProgram = _currentProgram with { Nodes = normalizedNodes };
}
}
private int ResolveInsertAfterIndex(CncNodeType nodeType)
{
if (_currentProgram == null || _currentProgram.Nodes.Count == 0)
@@ -432,8 +500,19 @@ namespace XplorePlane.ViewModels.Cnc
return -1;
}
if (TryResolvePendingInsertAfterIndex(nodeType, out int pendingAfterIndex))
{
return pendingAfterIndex;
}
if (!IsSavePositionChild(nodeType))
{
int? currentSavePositionIndex = FindOwningSavePositionIndex(SelectedNode?.Index);
if (currentSavePositionIndex.HasValue)
{
return GetSavePositionBlockEndIndex(currentSavePositionIndex.Value);
}
return SelectedNode?.Index ?? _currentProgram.Nodes.Count - 1;
}
@@ -446,6 +525,75 @@ namespace XplorePlane.ViewModels.Cnc
return GetSavePositionBlockEndIndex(savePositionIndex.Value);
}
private void SetPendingInsertAnchor(CncNodeViewModel nodeVm, bool insertAfter)
{
if (_currentProgram == null || nodeVm == null)
{
return;
}
_pendingInsertAnchorNodeId = nodeVm.Id;
_pendingInsertAfterAnchor = insertAfter;
SelectedNode = nodeVm;
}
private bool TryResolvePendingInsertAfterIndex(CncNodeType nodeType, out int afterIndex)
{
afterIndex = -1;
if (!_pendingInsertAnchorNodeId.HasValue || _currentProgram == null || IsSavePositionChild(nodeType))
{
return false;
}
int anchorIndex = FindNodeIndexById(_pendingInsertAnchorNodeId.Value);
if (anchorIndex < 0)
{
ClearPendingInsertAnchor();
return false;
}
afterIndex = _pendingInsertAfterAnchor ? anchorIndex : anchorIndex - 1;
return true;
}
private int FindNodeIndexById(Guid nodeId)
{
if (_currentProgram?.Nodes == null)
{
return -1;
}
for (int i = 0; i < _currentProgram.Nodes.Count; i++)
{
if (_currentProgram.Nodes[i].Id == nodeId)
{
return i;
}
}
return -1;
}
private void ClearPendingInsertAnchor()
{
_pendingInsertAnchorNodeId = null;
_pendingInsertAfterAnchor = false;
}
private void ClearPendingInsertAnchorIfMissing()
{
if (!_pendingInsertAnchorNodeId.HasValue)
{
return;
}
if (FindNodeIndexById(_pendingInsertAnchorNodeId.Value) < 0)
{
ClearPendingInsertAnchor();
}
}
private void MoveSavePositionChild(CncNodeViewModel nodeVm, bool moveDown)
{
int? parentIndex = FindOwningSavePositionIndex(nodeVm.Index);
@@ -598,10 +746,7 @@ namespace XplorePlane.ViewModels.Cnc
private CncProgram ReplaceProgramNodes(List<CncNode> nodes)
{
var renumberedNodes = nodes
.Select((node, index) => node with { Index = index })
.ToList()
.AsReadOnly();
var renumberedNodes = ApplyDefaultNodeNames(nodes);
return _currentProgram with
{
@@ -610,6 +755,28 @@ namespace XplorePlane.ViewModels.Cnc
};
}
private static IReadOnlyList<CncNode> ApplyDefaultNodeNames(IReadOnlyList<CncNode> nodes)
{
var result = new List<CncNode>(nodes.Count);
int referencePointNumber = 0;
int savePositionNumber = 0;
int inspectionModuleNumber = 0;
for (int i = 0; i < nodes.Count; i++)
{
var indexedNode = nodes[i] with { Index = i };
result.Add(indexedNode switch
{
ReferencePointNode referencePointNode => referencePointNode with { Name = $"\u53C2\u8003\u70B9_{referencePointNumber++}" },
SavePositionNode savePositionNode => savePositionNode with { Name = $"\u4FDD\u5B58\u4F4D\u7F6E_{savePositionNumber++}" },
InspectionModuleNode inspectionModuleNode => inspectionModuleNode with { Name = $"\u68C0\u6D4B\u6A21\u5757_{inspectionModuleNumber++}" },
_ => indexedNode
});
}
return result.AsReadOnly();
}
private static bool IsSavePositionChild(CncNodeType type)
{
return type is CncNodeType.InspectionMarker
@@ -622,5 +789,11 @@ namespace XplorePlane.ViewModels.Cnc
.GetEvent<CncProgramChangedEvent>()
.Publish(new CncProgramChangedPayload(ProgramName ?? string.Empty, IsModified));
}
private static string FormatProgramDisplayName(string programName)
{
var name = string.IsNullOrWhiteSpace(programName) ? "新建检测程序" : programName;
return name.EndsWith(".xp", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.xp";
}
}
}
@@ -1,4 +1,4 @@
using Microsoft.Win32;
using Microsoft.Win32;
using Prism.Commands;
using Prism.Mvvm;
using System;
@@ -26,6 +26,7 @@ namespace XplorePlane.ViewModels.Cnc
private CncNodeViewModel _activeModuleNode;
private PipelineNodeViewModel _selectedNode;
private string _statusMessage = "请选择检测模块以编辑其流水线。";
private string _pipelineFileDisplayName = "未命名模块.xpm";
private string _currentFilePath;
private bool _isSynchronizing;
@@ -44,6 +45,8 @@ namespace XplorePlane.ViewModels.Cnc
AddOperatorCommand = new DelegateCommand<string>(AddOperator, _ => HasActiveModule);
RemoveOperatorCommand = new DelegateCommand<PipelineNodeViewModel>(RemoveOperator);
ReorderOperatorCommand = new DelegateCommand<PipelineReorderArgs>(ReorderOperator);
ToggleOperatorEnabledCommand = new DelegateCommand<PipelineNodeViewModel>(ToggleOperatorEnabled);
MoveNodeUpCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeUp);
MoveNodeDownCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeDown);
NewPipelineCommand = new DelegateCommand(NewPipeline);
@@ -69,6 +72,14 @@ namespace XplorePlane.ViewModels.Cnc
private set => SetProperty(ref _statusMessage, value);
}
public bool IsStatusError => false;
public string PipelineFileDisplayName
{
get => _pipelineFileDisplayName;
private set => SetProperty(ref _pipelineFileDisplayName, value);
}
public bool HasActiveModule => _activeModuleNode?.IsInspectionModule == true;
public Visibility EditorVisibility => HasActiveModule ? Visibility.Visible : Visibility.Collapsed;
@@ -79,6 +90,10 @@ namespace XplorePlane.ViewModels.Cnc
public ICommand RemoveOperatorCommand { get; }
public ICommand ReorderOperatorCommand { get; }
public ICommand ToggleOperatorEnabledCommand { get; }
public ICommand MoveNodeUpCommand { get; }
public ICommand MoveNodeDownCommand { get; }
@@ -107,6 +122,7 @@ namespace XplorePlane.ViewModels.Cnc
_activeModuleNode = null;
PipelineNodes.Clear();
SelectedNode = null;
PipelineFileDisplayName = "未命名模块.xpm";
StatusMessage = "请选择检测模块以编辑其流水线。";
RaiseModuleVisibilityChanged();
RaiseCommandCanExecuteChanged();
@@ -114,6 +130,7 @@ namespace XplorePlane.ViewModels.Cnc
}
_activeModuleNode = selected;
_currentFilePath = null;
LoadPipelineModel(_activeModuleNode.Pipeline ?? new PipelineModel
{
Name = _activeModuleNode.Name
@@ -152,13 +169,10 @@ namespace XplorePlane.ViewModels.Cnc
if (!HasActiveModule || node == null || !PipelineNodes.Contains(node))
return;
var removedIndex = PipelineNodes.IndexOf(node);
PipelineNodes.Remove(node);
RenumberNodes();
if (SelectedNode == node)
{
SelectedNode = PipelineNodes.LastOrDefault();
}
SelectNeighborAfterRemoval(removedIndex);
PersistActiveModule($"已移除算子:{node.DisplayName}");
}
@@ -177,6 +191,26 @@ namespace XplorePlane.ViewModels.Cnc
PersistActiveModule($"已上移算子:{node.DisplayName}");
}
private void ReorderOperator(PipelineReorderArgs args)
{
if (!HasActiveModule || args == null)
return;
var oldIndex = args.OldIndex;
var newIndex = args.NewIndex;
if (oldIndex < 0 || oldIndex >= PipelineNodes.Count)
return;
if (newIndex < 0 || newIndex >= PipelineNodes.Count || oldIndex == newIndex)
return;
var node = PipelineNodes[oldIndex];
PipelineNodes.Move(oldIndex, newIndex);
RenumberNodes();
SelectedNode = node;
PersistActiveModule($"已调整算子顺序:{node.DisplayName}");
}
private void MoveNodeDown(PipelineNodeViewModel node)
{
if (!HasActiveModule || node == null)
@@ -191,6 +225,18 @@ namespace XplorePlane.ViewModels.Cnc
PersistActiveModule($"已下移算子:{node.DisplayName}");
}
private void ToggleOperatorEnabled(PipelineNodeViewModel node)
{
if (!HasActiveModule || node == null || !PipelineNodes.Contains(node))
return;
node.IsEnabled = !node.IsEnabled;
SelectedNode = node;
PersistActiveModule(node.IsEnabled
? $"已启用算子:{node.DisplayName}"
: $"已停用算子:{node.DisplayName}");
}
private void NewPipeline()
{
if (!HasActiveModule)
@@ -199,6 +245,7 @@ namespace XplorePlane.ViewModels.Cnc
PipelineNodes.Clear();
SelectedNode = null;
_currentFilePath = null;
PipelineFileDisplayName = GetActivePipelineFileDisplayName();
PersistActiveModule("已为当前检测模块新建空流水线。");
}
@@ -217,7 +264,9 @@ namespace XplorePlane.ViewModels.Cnc
var dialog = new SaveFileDialog
{
Filter = "图像处理流水线 (*.imw)|*.imw",
Filter = "XP 模块流水线 (*.xpm)|*.xpm",
DefaultExt = ".xpm",
AddExtension = true,
FileName = GetActivePipelineName()
};
@@ -227,6 +276,7 @@ namespace XplorePlane.ViewModels.Cnc
var model = BuildPipelineModel();
await _persistenceService.SaveAsync(model, dialog.FileName);
_currentFilePath = dialog.FileName;
PipelineFileDisplayName = FormatPipelinePath(dialog.FileName);
StatusMessage = $"已导出模块流水线:{Path.GetFileName(dialog.FileName)}";
}
@@ -237,7 +287,8 @@ namespace XplorePlane.ViewModels.Cnc
var dialog = new OpenFileDialog
{
Filter = "图像处理流水线 (*.imw)|*.imw"
Filter = "XP 模块流水线 (*.xpm)|*.xpm",
DefaultExt = ".xpm"
};
if (dialog.ShowDialog() != true)
@@ -245,6 +296,7 @@ namespace XplorePlane.ViewModels.Cnc
var model = await _persistenceService.LoadAsync(dialog.FileName);
_currentFilePath = dialog.FileName;
PipelineFileDisplayName = FormatPipelinePath(dialog.FileName);
LoadPipelineModel(model);
PersistActiveModule($"已加载模块流水线:{model.Name}");
}
@@ -276,6 +328,8 @@ namespace XplorePlane.ViewModels.Cnc
}
SelectedNode = PipelineNodes.FirstOrDefault();
if (string.IsNullOrEmpty(_currentFilePath))
PipelineFileDisplayName = GetActivePipelineFileDisplayName();
StatusMessage = HasActiveModule
? $"正在编辑检测模块:{_activeModuleNode.Name}"
: "请选择检测模块以编辑其流水线。";
@@ -351,6 +405,21 @@ namespace XplorePlane.ViewModels.Cnc
: _activeModuleNode.Pipeline.Name;
}
private string GetActivePipelineFileDisplayName()
{
var pipelineName = GetActivePipelineName();
return pipelineName.EndsWith(".xpm", StringComparison.OrdinalIgnoreCase)
? pipelineName
: $"{pipelineName}.xpm";
}
private static string FormatPipelinePath(string filePath)
{
return string.IsNullOrWhiteSpace(filePath)
? "未命名模块.xpm"
: Path.GetFullPath(filePath).Replace('\\', '/');
}
private void RenumberNodes()
{
for (var i = 0; i < PipelineNodes.Count; i++)
@@ -359,6 +428,20 @@ namespace XplorePlane.ViewModels.Cnc
}
}
private void SelectNeighborAfterRemoval(int removedIndex)
{
if (PipelineNodes.Count == 0)
{
SelectedNode = null;
return;
}
var nextIndex = removedIndex < PipelineNodes.Count
? removedIndex
: PipelineNodes.Count - 1;
SelectedNode = PipelineNodes[nextIndex];
}
private void RaiseModuleVisibilityChanged()
{
RaisePropertyChanged(nameof(HasActiveModule));
@@ -37,6 +37,8 @@ namespace XplorePlane.ViewModels.Cnc
set => UpdateModel(_model with { Name = value ?? string.Empty });
}
public bool IsReadOnlyNodeProperties => IsReferencePoint || IsSavePosition;
public CncNodeType NodeType => _model.NodeType;
public string NodeTypeDisplay => NodeType.ToString();
@@ -0,0 +1,31 @@
using Prism.Mvvm;
using System.Collections.ObjectModel;
namespace XplorePlane.ViewModels.Cnc
{
public class CncProgramTreeRootViewModel : BindableBase
{
private string _displayName;
private bool _isExpanded = true;
public CncProgramTreeRootViewModel(string displayName, ObservableCollection<CncNodeViewModel> children)
{
_displayName = displayName;
Children = children;
}
public string DisplayName
{
get => _displayName;
set => SetProperty(ref _displayName, value);
}
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
public ObservableCollection<CncNodeViewModel> Children { get; }
}
}
@@ -11,10 +11,18 @@ namespace XplorePlane.ViewModels
string StatusMessage { get; }
bool IsStatusError { get; }
string PipelineFileDisplayName { get; }
ICommand AddOperatorCommand { get; }
ICommand RemoveOperatorCommand { get; }
ICommand ReorderOperatorCommand { get; }
ICommand ToggleOperatorEnabledCommand { get; }
ICommand MoveNodeUpCommand { get; }
ICommand MoveNodeDownCommand { get; }
@@ -1,4 +1,4 @@
using Microsoft.Win32;
using Microsoft.Win32;
using Prism.Events;
using Prism.Commands;
using Prism.Mvvm;
@@ -21,6 +21,7 @@ namespace XplorePlane.ViewModels
{
private const int MaxPipelineLength = 20;
private const int DebounceDelayMs = 300;
private const string DefaultPipelineFileDisplayName = "未命名模块.xpm";
private readonly IImageProcessingService _imageProcessingService;
private readonly IPipelineExecutionService _executionService;
@@ -34,7 +35,9 @@ namespace XplorePlane.ViewModels
private string _pipelineName = "新建流水线";
private string _selectedDevice = string.Empty;
private bool _isExecuting;
private bool _isStatusError;
private string _statusMessage = string.Empty;
private string _pipelineFileDisplayName = DefaultPipelineFileDisplayName;
private string _currentFilePath;
private CancellationTokenSource _executionCts;
@@ -59,6 +62,7 @@ namespace XplorePlane.ViewModels
AddOperatorCommand = new DelegateCommand<string>(AddOperator, CanAddOperator);
RemoveOperatorCommand = new DelegateCommand<PipelineNodeViewModel>(RemoveOperator);
ReorderOperatorCommand = new DelegateCommand<PipelineReorderArgs>(ReorderOperator);
ToggleOperatorEnabledCommand = new DelegateCommand<PipelineNodeViewModel>(ToggleOperatorEnabled);
ExecutePipelineCommand = new DelegateCommand(async () => await ExecutePipelineAsync(), () => !IsExecuting && SourceImage != null);
CancelExecutionCommand = new DelegateCommand(CancelExecution, () => IsExecuting);
NewPipelineCommand = new DelegateCommand(NewPipeline);
@@ -147,11 +151,24 @@ namespace XplorePlane.ViewModels
set => SetProperty(ref _statusMessage, value);
}
public bool IsStatusError
{
get => _isStatusError;
private set => SetProperty(ref _isStatusError, value);
}
public string PipelineFileDisplayName
{
get => _pipelineFileDisplayName;
private set => SetProperty(ref _pipelineFileDisplayName, value);
}
// ── Commands ──────────────────────────────────────────────────
public DelegateCommand<string> AddOperatorCommand { get; }
public DelegateCommand<PipelineNodeViewModel> RemoveOperatorCommand { get; }
public DelegateCommand<PipelineReorderArgs> ReorderOperatorCommand { get; }
public DelegateCommand<PipelineNodeViewModel> ToggleOperatorEnabledCommand { get; }
public DelegateCommand ExecutePipelineCommand { get; }
public DelegateCommand CancelExecutionCommand { get; }
public DelegateCommand NewPipelineCommand { get; }
@@ -168,6 +185,8 @@ namespace XplorePlane.ViewModels
ICommand IPipelineEditorHostViewModel.AddOperatorCommand => AddOperatorCommand;
ICommand IPipelineEditorHostViewModel.RemoveOperatorCommand => RemoveOperatorCommand;
ICommand IPipelineEditorHostViewModel.ReorderOperatorCommand => ReorderOperatorCommand;
ICommand IPipelineEditorHostViewModel.ToggleOperatorEnabledCommand => ToggleOperatorEnabledCommand;
ICommand IPipelineEditorHostViewModel.MoveNodeUpCommand => MoveNodeUpCommand;
ICommand IPipelineEditorHostViewModel.MoveNodeDownCommand => MoveNodeDownCommand;
ICommand IPipelineEditorHostViewModel.NewPipelineCommand => NewPipelineCommand;
@@ -186,7 +205,7 @@ namespace XplorePlane.ViewModels
if (string.IsNullOrWhiteSpace(operatorKey))
{
StatusMessage = "算子键不能为空";
SetInfoStatus("算子键不能为空");
_logger.Warn("AddOperator 失败:operatorKey 为空");
return;
}
@@ -197,14 +216,14 @@ namespace XplorePlane.ViewModels
if (!available.Contains(operatorKey))
{
StatusMessage = $"算子 '{operatorKey}' 未注册";
SetInfoStatus($"算子 '{operatorKey}' 未注册");
_logger.Warn("AddOperator 失败:算子 {Key} 未注册", operatorKey);
return;
}
if (PipelineNodes.Count >= MaxPipelineLength)
{
StatusMessage = $"流水线节点数已达上限({MaxPipelineLength}";
SetInfoStatus($"流水线节点数已达上限({MaxPipelineLength}");
_logger.Warn("AddOperator 失败:节点数已达上限 {Max}", MaxPipelineLength);
return;
}
@@ -217,9 +236,10 @@ namespace XplorePlane.ViewModels
};
LoadNodeParameters(node);
PipelineNodes.Add(node);
SelectedNode = node;
_logger.Info("节点已添加到 PipelineNodes{Key} ({DisplayName}),当前节点数={Count}",
operatorKey, displayName, PipelineNodes.Count);
StatusMessage = $"已添加算子:{displayName}";
SetInfoStatus($"已添加算子:{displayName}");
TriggerDebouncedExecution();
}
@@ -227,13 +247,12 @@ namespace XplorePlane.ViewModels
{
if (node == null || !PipelineNodes.Contains(node)) return;
var removedIndex = PipelineNodes.IndexOf(node);
PipelineNodes.Remove(node);
RenumberNodes();
SelectNeighborAfterRemoval(removedIndex);
if (SelectedNode == node)
SelectedNode = null;
StatusMessage = $"已移除算子:{node.DisplayName}";
SetInfoStatus($"已移除算子:{node.DisplayName}");
TriggerDebouncedExecution();
}
@@ -271,6 +290,20 @@ namespace XplorePlane.ViewModels
PipelineNodes.RemoveAt(oldIndex);
PipelineNodes.Insert(newIndex, node);
RenumberNodes();
SelectedNode = node;
SetInfoStatus($"已调整算子顺序:{node.DisplayName}");
TriggerDebouncedExecution();
}
private void ToggleOperatorEnabled(PipelineNodeViewModel node)
{
if (node == null || !PipelineNodes.Contains(node)) return;
node.IsEnabled = !node.IsEnabled;
SelectedNode = node;
SetInfoStatus(node.IsEnabled
? $"已启用算子:{node.DisplayName}"
: $"已停用算子:{node.DisplayName}");
TriggerDebouncedExecution();
}
@@ -280,6 +313,20 @@ namespace XplorePlane.ViewModels
PipelineNodes[i].Order = i;
}
private void SelectNeighborAfterRemoval(int removedIndex)
{
if (PipelineNodes.Count == 0)
{
SelectedNode = null;
return;
}
var nextIndex = removedIndex < PipelineNodes.Count
? removedIndex
: PipelineNodes.Count - 1;
SelectedNode = PipelineNodes[nextIndex];
}
private void LoadNodeParameters(PipelineNodeViewModel node)
{
var paramDefs = _imageProcessingService.GetProcessorParameters(node.OperatorKey);
@@ -297,7 +344,12 @@ namespace XplorePlane.ViewModels
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ProcessorParameterVM.Value))
{
if (TryReportInvalidParameters())
return;
TriggerDebouncedExecution();
}
};
node.Parameters.Add(vm);
}
@@ -307,36 +359,39 @@ namespace XplorePlane.ViewModels
{
if (SourceImage == null || IsExecuting) return;
if (TryReportInvalidParameters())
return;
_executionCts?.Cancel();
_executionCts = new CancellationTokenSource();
var token = _executionCts.Token;
IsExecuting = true;
StatusMessage = "正在执行流水线...";
SetInfoStatus("正在执行流水线...");
try
{
var progress = new Progress<PipelineProgress>(p =>
StatusMessage = $"执行中:{p.CurrentOperator} ({p.CurrentStep}/{p.TotalSteps})");
SetInfoStatus($"执行中:{p.CurrentOperator} ({p.CurrentStep}/{p.TotalSteps})"));
var result = await _executionService.ExecutePipelineAsync(
PipelineNodes, SourceImage, progress, token);
PreviewImage = result;
StatusMessage = "流水线执行完成";
SetInfoStatus("流水线执行完成");
PublishPipelinePreviewUpdated(result, StatusMessage);
}
catch (OperationCanceledException)
{
StatusMessage = "流水线执行已取消";
SetInfoStatus("流水线执行已取消");
}
catch (PipelineExecutionException ex)
{
StatusMessage = $"节点 '{ex.FailedOperatorKey}' 执行失败:{ex.Message}";
SetErrorStatus($"执行失败:{ex.Message}");
}
catch (Exception ex)
{
StatusMessage = $"执行错误:{ex.Message}";
SetErrorStatus($"执行错误:{ex.Message}");
}
finally
{
@@ -344,6 +399,36 @@ namespace XplorePlane.ViewModels
}
}
private bool TryReportInvalidParameters()
{
var firstInvalidNode = PipelineNodes
.Where(n => n.IsEnabled)
.OrderBy(n => n.Order)
.FirstOrDefault(n => n.Parameters.Any(p => !p.IsValueValid));
if (firstInvalidNode == null)
return false;
var invalidNames = firstInvalidNode.Parameters
.Where(p => !p.IsValueValid)
.Select(p => p.DisplayName);
SetErrorStatus($"参数错误:算子 '{firstInvalidNode.DisplayName}' 的 {string.Join("", invalidNames)} 输入不合理,请修正后重试。");
return true;
}
private void SetInfoStatus(string message)
{
IsStatusError = false;
StatusMessage = message;
}
private void SetErrorStatus(string message)
{
IsStatusError = true;
StatusMessage = message;
PublishPipelinePreviewUpdated(PreviewImage ?? SourceImage, message);
}
private void LoadImage()
{
var dialog = new OpenFileDialog
@@ -361,7 +446,7 @@ namespace XplorePlane.ViewModels
}
catch (Exception ex)
{
StatusMessage = $"加载图像失败:{ex.Message}";
SetErrorStatus($"加载图像失败:{ex.Message}");
_logger.Error(ex, "加载图像失败:{Path}", dialog.FileName);
}
}
@@ -380,7 +465,7 @@ namespace XplorePlane.ViewModels
SourceImage = bitmap;
PreviewImage = bitmap;
StatusMessage = $"已加载图像:{Path.GetFileName(filePath)}";
SetInfoStatus($"已加载图像:{Path.GetFileName(filePath)}");
PublishManualImageLoaded(bitmap, filePath);
}
@@ -391,7 +476,7 @@ namespace XplorePlane.ViewModels
SourceImage = bitmap;
PreviewImage = bitmap;
StatusMessage = $"已加载图像:{Path.GetFileName(filePath)}";
SetInfoStatus($"已加载图像:{Path.GetFileName(filePath)}");
PublishManualImageLoaded(bitmap, filePath);
if (runPipeline)
@@ -419,7 +504,7 @@ namespace XplorePlane.ViewModels
SourceImage = payload.Image;
PreviewImage = payload.Image;
StatusMessage = $"已加载图像:{payload.FileName}";
SetInfoStatus($"已加载图像:{payload.FileName}");
}
private void CancelExecution()
@@ -449,14 +534,15 @@ namespace XplorePlane.ViewModels
PipelineName = "新建流水线";
PreviewImage = null;
_currentFilePath = null;
StatusMessage = "已新建流水线";
PipelineFileDisplayName = DefaultPipelineFileDisplayName;
SetInfoStatus("已新建流水线");
}
private async Task SavePipelineAsync()
{
if (string.IsNullOrWhiteSpace(PipelineName) || PipelineName.Length > 100)
{
StatusMessage = "流水线名称不能为空且长度不超过 100 个字符";
SetInfoStatus("流水线名称不能为空且长度不超过 100 个字符");
return;
}
@@ -473,13 +559,15 @@ namespace XplorePlane.ViewModels
{
if (string.IsNullOrWhiteSpace(PipelineName) || PipelineName.Length > 100)
{
StatusMessage = "流水线名称不能为空且长度不超过 100 个字符";
SetInfoStatus("流水线名称不能为空且长度不超过 100 个字符");
return;
}
var dialog = new SaveFileDialog
{
Filter = "图像处理流水线 (*.imw)|*.imw",
Filter = "XP 模块流水线 (*.xpm)|*.xpm",
DefaultExt = ".xpm",
AddExtension = true,
FileName = PipelineName,
InitialDirectory = GetPipelineDirectory()
};
@@ -497,11 +585,12 @@ namespace XplorePlane.ViewModels
{
var model = BuildPipelineModel();
await _persistenceService.SaveAsync(model, filePath);
StatusMessage = $"流水线已保存:{Path.GetFileName(filePath)}";
PipelineFileDisplayName = FormatPipelinePath(filePath);
SetInfoStatus($"流水线已保存:{Path.GetFileName(filePath)}");
}
catch (IOException ex)
{
StatusMessage = $"保存失败:{ex.Message}";
SetErrorStatus($"保存失败:{ex.Message}");
}
}
@@ -515,11 +604,11 @@ namespace XplorePlane.ViewModels
File.Delete(_currentFilePath);
NewPipeline();
StatusMessage = "流水线已删除";
SetInfoStatus("流水线已删除");
}
catch (IOException ex)
{
StatusMessage = $"删除失败:{ex.Message}";
SetErrorStatus($"删除失败:{ex.Message}");
}
await Task.CompletedTask;
}
@@ -528,7 +617,8 @@ namespace XplorePlane.ViewModels
{
var dialog = new OpenFileDialog
{
Filter = "图像处理流水线 (*.imw)|*.imw",
Filter = "XP 模块流水线 (*.xpm)|*.xpm",
DefaultExt = ".xpm",
InitialDirectory = GetPipelineDirectory()
};
@@ -544,6 +634,7 @@ namespace XplorePlane.ViewModels
PipelineName = model.Name;
SelectedDevice = model.DeviceId;
_currentFilePath = dialog.FileName;
PipelineFileDisplayName = FormatPipelinePath(dialog.FileName);
foreach (var nodeModel in model.Nodes)
{
@@ -568,12 +659,12 @@ namespace XplorePlane.ViewModels
}
_logger.Info("流水线已加载:{Name},节点数={Count}", model.Name, PipelineNodes.Count);
StatusMessage = $"已加载流水线:{model.Name}{PipelineNodes.Count} 个节点)";
SetInfoStatus($"已加载流水线:{model.Name}{PipelineNodes.Count} 个节点)");
}
catch (Exception ex)
{
_logger.Warn("加载流水线失败:{Error}", ex.Message);
StatusMessage = $"加载失败:{ex.Message}";
SetErrorStatus($"加载失败:{ex.Message}");
}
}
@@ -622,5 +713,12 @@ namespace XplorePlane.ViewModels
Directory.CreateDirectory(dir);
return dir;
}
private static string FormatPipelinePath(string filePath)
{
return string.IsNullOrWhiteSpace(filePath)
? DefaultPipelineFileDisplayName
: Path.GetFullPath(filePath).Replace('\\', '/');
}
}
}
@@ -19,6 +19,7 @@ namespace XplorePlane.ViewModels
MinValue = parameter.MinValue;
MaxValue = parameter.MaxValue;
Options = parameter.Options;
IsVisible = parameter.IsVisible;
ParameterType = parameter.ValueType?.Name?.ToLowerInvariant() switch
{
"int32" or "int" => "int",
@@ -34,7 +35,11 @@ namespace XplorePlane.ViewModels
public object MinValue { get; }
public object MaxValue { get; }
public string[]? Options { get; }
public bool IsVisible { get; }
public string ParameterType { get; }
public bool HasOptions => Options is { Length: > 0 };
public bool IsBool => ParameterType == "bool";
public bool IsTextInput => !IsBool && !HasOptions;
public bool IsValueValid
{
@@ -48,8 +53,40 @@ namespace XplorePlane.ViewModels
set
{
var normalizedValue = NormalizeValue(value);
if (SetProperty(ref _value, normalizedValue))
if (!Equals(_value, normalizedValue))
{
_value = normalizedValue;
ValidateValue(normalizedValue);
RaisePropertyChanged(nameof(Value));
RaisePropertyChanged(nameof(BoolValue));
RaisePropertyChanged(nameof(SelectedOption));
}
}
}
public bool BoolValue
{
get => ParameterType == "bool" && TryConvertToBool(_value, out var boolValue) && boolValue;
set
{
if (ParameterType == "bool")
{
Value = value;
}
}
}
public string SelectedOption
{
get => HasOptions
? Convert.ToString(_value, CultureInfo.InvariantCulture) ?? string.Empty
: string.Empty;
set
{
if (HasOptions)
{
Value = value;
}
}
}
+48 -1
View File
@@ -21,7 +21,7 @@ namespace XplorePlane.ViewModels
{
public class MainViewModel : BindableBase
{
private const double CncEditorHostWidth = 710d;
private const double CncEditorHostWidth = 502d;
private readonly ILoggerService _logger;
private readonly IContainerProvider _containerProvider;
private readonly IEventAggregator _eventAggregator;
@@ -75,6 +75,15 @@ namespace XplorePlane.ViewModels
public DelegateCommand OpenRaySourceConfigCommand { get; }
public DelegateCommand WarmUpCommand { get; }
// 测量命令
public DelegateCommand PointDistanceMeasureCommand { get; }
public DelegateCommand PointLineDistanceMeasureCommand { get; }
public DelegateCommand AngleMeasureCommand { get; }
public DelegateCommand ThroughHoleFillRateMeasureCommand { get; }
// 辅助线命令
public DelegateCommand ToggleCrosshairCommand { get; }
// 设置命令
public DelegateCommand OpenLanguageSwitcherCommand { get; }
public DelegateCommand OpenRealTimeLogViewerCommand { get; }
@@ -152,6 +161,16 @@ namespace XplorePlane.ViewModels
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertPauseDialogCommand.Execute()));
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertWaitDelayCommand.Execute()));
// 测量命令
PointDistanceMeasureCommand = new DelegateCommand(ExecutePointDistanceMeasure);
PointLineDistanceMeasureCommand = new DelegateCommand(ExecutePointLineDistanceMeasure);
AngleMeasureCommand = new DelegateCommand(ExecuteAngleMeasure);
ThroughHoleFillRateMeasureCommand = new DelegateCommand(ExecuteThroughHoleFillRateMeasure);
// 辅助线命令
ToggleCrosshairCommand = new DelegateCommand(() =>
_eventAggregator.GetEvent<ToggleCrosshairEvent>().Publish());
// 硬件命令
AxisResetCommand = new DelegateCommand(ExecuteAxisReset);
OpenDetectorConfigCommand = new DelegateCommand(ExecuteOpenDetectorConfig);
@@ -419,6 +438,34 @@ namespace XplorePlane.ViewModels
#endregion
#region
private void ExecutePointDistanceMeasure()
{
_logger.Info("点点距测量功能已触发");
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.PointDistance);
}
private void ExecutePointLineDistanceMeasure()
{
_logger.Info("点线距测量功能已触发");
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.PointLineDistance);
}
private void ExecuteAngleMeasure()
{
_logger.Info("角度测量功能已触发");
// TODO: 实现角度测量逻辑
}
private void ExecuteThroughHoleFillRateMeasure()
{
_logger.Info("通孔填锡率测量功能已触发");
// TODO: 实现通孔填锡率测量逻辑
}
#endregion
#region
private void ExecuteOpenLanguageSwitcher()
@@ -1,3 +1,4 @@
using Prism.Commands;
using Prism.Events;
using Prism.Mvvm;
using System;
@@ -18,6 +19,7 @@ namespace XplorePlane.ViewModels
public class ViewportPanelViewModel : BindableBase
{
private readonly ILoggerService _logger;
private readonly IEventAggregator _eventAggregator;
private int _isProcessingFrame;
private ImageSource _imageSource;
@@ -34,9 +36,116 @@ namespace XplorePlane.ViewModels
set => SetProperty(ref _imageInfo, value);
}
#region
private MeasurementToolMode _currentMeasurementMode = MeasurementToolMode.None;
public MeasurementToolMode CurrentMeasurementMode
{
get => _currentMeasurementMode;
set
{
if (SetProperty(ref _currentMeasurementMode, value))
{
RaisePropertyChanged(nameof(IsMeasuring));
RaisePropertyChanged(nameof(MeasurementModeText));
// 切换模式时重置状态
ResetMeasurementState();
}
}
}
public bool IsMeasuring => CurrentMeasurementMode != MeasurementToolMode.None;
public string MeasurementModeText => CurrentMeasurementMode switch
{
MeasurementToolMode.PointDistance => "点点距测量 - 请在图像上点击第一个点",
_ => string.Empty
};
// 测量点坐标(图像像素坐标)
private Point? _measurePoint1;
public Point? MeasurePoint1
{
get => _measurePoint1;
set => SetProperty(ref _measurePoint1, value);
}
private Point? _measurePoint2;
public Point? MeasurePoint2
{
get => _measurePoint2;
set => SetProperty(ref _measurePoint2, value);
}
private string _measurementResult;
public string MeasurementResult
{
get => _measurementResult;
set => SetProperty(ref _measurementResult, value);
}
/// <summary>
/// 由 View 层调用:用户在画布上点击了一个点(像素坐标)
/// </summary>
public void OnMeasurementPointClicked(Point imagePoint)
{
if (CurrentMeasurementMode == MeasurementToolMode.PointDistance)
{
if (MeasurePoint1 == null)
{
MeasurePoint1 = imagePoint;
ImageInfo = $"点点距测量 - 第一点: ({imagePoint.X:F0}, {imagePoint.Y:F0}),请点击第二个点";
_logger?.Info("测量第一点: ({X}, {Y})", imagePoint.X, imagePoint.Y);
}
else
{
MeasurePoint2 = imagePoint;
CalculatePointDistance();
}
}
}
private void CalculatePointDistance()
{
if (MeasurePoint1 == null || MeasurePoint2 == null) return;
var p1 = MeasurePoint1.Value;
var p2 = MeasurePoint2.Value;
double dx = p2.X - p1.X;
double dy = p2.Y - p1.Y;
double distance = Math.Sqrt(dx * dx + dy * dy);
double angle = Math.Atan2(dy, dx) * 180.0 / Math.PI;
MeasurementResult = $"{distance:F2} px";
ImageInfo = $"点点距: {distance:F2} px | 角度: {angle:F2}° | ({p1.X:F0},{p1.Y:F0}) → ({p2.X:F0},{p2.Y:F0})";
_logger?.Info("点点距测量完成: {Distance:F2} px, 角度: {Angle:F2}°", distance, angle);
}
/// <summary>
/// 取消/重置当前测量
/// </summary>
public DelegateCommand CancelMeasurementCommand { get; private set; }
public void ResetMeasurementState()
{
MeasurePoint1 = null;
MeasurePoint2 = null;
MeasurementResult = null;
}
#endregion
public ViewportPanelViewModel(IEventAggregator eventAggregator, ILoggerService logger)
{
_logger = logger?.ForModule<ViewportPanelViewModel>();
_eventAggregator = eventAggregator;
CancelMeasurementCommand = new DelegateCommand(() =>
{
// 发布 None 事件,让 View 层也收到
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.None);
ImageInfo = "测量已取消";
});
eventAggregator.GetEvent<ImageCapturedEvent>()
.Subscribe(OnImageCaptured, ThreadOption.BackgroundThread);
@@ -44,8 +153,39 @@ namespace XplorePlane.ViewModels
.Subscribe(OnManualImageLoaded, ThreadOption.UIThread);
eventAggregator.GetEvent<PipelinePreviewUpdatedEvent>()
.Subscribe(OnPipelinePreviewUpdated, ThreadOption.UIThread);
// 订阅测量工具事件
eventAggregator.GetEvent<MeasurementToolEvent>()
.Subscribe(OnMeasurementToolActivated, ThreadOption.UIThread);
// 订阅十字辅助线切换事件
eventAggregator.GetEvent<ToggleCrosshairEvent>()
.Subscribe(OnToggleCrosshair, ThreadOption.UIThread);
}
private void OnMeasurementToolActivated(MeasurementToolMode mode)
{
CurrentMeasurementMode = mode;
_logger?.Info("测量工具模式切换: {Mode}", mode);
}
#region 线
private bool _showCrosshair;
public bool ShowCrosshair
{
get => _showCrosshair;
set => SetProperty(ref _showCrosshair, value);
}
private void OnToggleCrosshair()
{
ShowCrosshair = !ShowCrosshair;
_logger?.Info("十字辅助线: {State}", ShowCrosshair ? "显示" : "隐藏");
}
#endregion
private void OnImageCaptured(ImageCapturedEventArgs args)
{
if (args?.ImageData == null || args.Width == 0 || args.Height == 0) return;
+267 -273
View File
@@ -9,22 +9,21 @@
xmlns:views="clr-namespace:XplorePlane.Views"
xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc"
d:DesignHeight="760"
d:DesignWidth="702"
d:DesignWidth="502"
prism:ViewModelLocator.AutoWireViewModel="True"
mc:Ignorable="d">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<local:InverseBooleanToVisibilityConverter x:Key="InverseBoolToVisibilityConverter" />
<local:BoolToDisplayTextConverter x:Key="BoolToDisplayTextConverter" />
<local:NullToVisibilityConverter x:Key="NullToVisibilityConverter" />
<SolidColorBrush x:Key="PanelBg" Color="White" />
<SolidColorBrush x:Key="PanelBorder" Color="#CDCBCB" />
<SolidColorBrush x:Key="SeparatorBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="HeaderBg" Color="#F7F7F7" />
<SolidColorBrush x:Key="TreeParentBg" Color="#F6F8FB" />
<SolidColorBrush x:Key="TreeChildBg" Color="#FBFCFE" />
<SolidColorBrush x:Key="TreeAccentBrush" Color="#2E6FA3" />
<SolidColorBrush x:Key="TreeChildLineBrush" Color="#B9CDE0" />
<SolidColorBrush x:Key="TreeChildLineBrush" Color="#C7D4DF" />
<FontFamily x:Key="UiFont">Microsoft YaHei UI</FontFamily>
<Style x:Key="TreeItemStyle" TargetType="TreeViewItem">
@@ -58,6 +57,19 @@
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="DisplayValueLabel" TargetType="Label">
<Setter Property="Height" Value="28" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="Background" Value="#F7F8FA" />
<Setter Property="BorderBrush" Value="#D9DDE3" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="#333333" />
</Style>
<Style x:Key="EditorCheck" TargetType="CheckBox">
<Setter Property="Margin" Value="0,2,0,8" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
@@ -70,11 +82,24 @@
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="TreeToolbarButton" TargetType="Button">
<Setter Property="Height" Value="24" />
<Setter Property="MinWidth" Value="42" />
<Setter Property="Margin" Value="0,0,4,0" />
<Setter Property="Padding" Value="8,0" />
<Setter Property="Background" Value="#F8F8F8" />
<Setter Property="BorderBrush" Value="#CFCFCF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
</Style>
</UserControl.Resources>
<Border
Width="702"
MinWidth="702"
Width="502"
MinWidth="502"
HorizontalAlignment="Left"
Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource PanelBorder}"
@@ -84,9 +109,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="1" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="1" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
@@ -97,37 +120,81 @@
<Border
Grid.Row="0"
Padding="10,8"
Padding="6,5"
Background="{StaticResource HeaderBg}"
BorderBrush="{StaticResource SeparatorBrush}"
BorderThickness="0,0,0,1">
<StackPanel>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="{Binding ProgramName, TargetNullValue=CNC编辑}"
TextWrapping="Wrap" />
<TextBlock
Margin="0,3,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="保存位置节点下会自动显示检测标记和检测模块,清晰表达当前检测位置与检测内容的父子关系。"
TextWrapping="Wrap" />
</StackPanel>
<WrapPanel>
<Button
Command="{Binding NewProgramCommand}"
Content="新建"
Style="{StaticResource TreeToolbarButton}" />
<Button
Command="{Binding SaveProgramCommand}"
Content="保存"
Style="{StaticResource TreeToolbarButton}" />
<Button
Command="{Binding LoadProgramCommand}"
Content="加载"
Style="{StaticResource TreeToolbarButton}" />
<Button
Command="{Binding ExportCsvCommand}"
Content="导出"
Style="{StaticResource TreeToolbarButton}" />
</WrapPanel>
</Border>
<TreeView
x:Name="CncTreeView"
Grid.Row="1"
Padding="4,6"
Padding="3,5"
Background="Transparent"
BorderThickness="0"
ItemsSource="{Binding TreeNodes}"
ItemsSource="{Binding ProgramTreeRoots}"
PreviewKeyDown="CncTreeView_PreviewKeyDown"
SelectedItemChanged="CncTreeView_SelectedItemChanged">
<TreeView.Resources>
<HierarchicalDataTemplate DataType="{x:Type vm:CncNodeViewModel}" ItemsSource="{Binding Children}">
<HierarchicalDataTemplate
DataType="{x:Type vm:CncProgramTreeRootViewModel}"
ItemContainerStyle="{StaticResource TreeItemStyle}"
ItemsSource="{Binding Children}">
<Border
x:Name="ProgramRootCard"
Margin="0,1,0,3"
Padding="0,2"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="1"
CornerRadius="4">
<StackPanel Orientation="Horizontal">
<TextBlock
Margin="1,0,4,0"
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="12"
Foreground="#2B8A3E"
Text="◆" />
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="12"
FontWeight="SemiBold"
Text="{Binding DisplayName}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TreeViewItem}}" Value="True">
<Setter TargetName="ProgramRootCard" Property="Background" Value="#E7F1FB" />
<Setter TargetName="ProgramRootCard" Property="BorderBrush" Value="#9FC6E8" />
</DataTrigger>
</DataTemplate.Triggers>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate
DataType="{x:Type vm:CncNodeViewModel}"
ItemContainerStyle="{StaticResource TreeItemStyle}"
ItemsSource="{Binding Children}">
<Border
x:Name="NodeCard"
Margin="0,1,0,1"
@@ -136,10 +203,10 @@
BorderBrush="Transparent"
BorderThickness="1"
CornerRadius="4">
<Grid x:Name="NodeRoot" MinHeight="28">
<Grid x:Name="NodeRoot" MinHeight="23">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="18" />
<ColumnDefinition Width="24" />
<ColumnDefinition Width="15" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
@@ -147,55 +214,44 @@
<Grid Grid.Column="0">
<Border
x:Name="ChildStem"
Width="2"
Margin="8,0,0,0"
Width="1"
Margin="7,0,0,0"
HorizontalAlignment="Left"
Background="{StaticResource TreeChildLineBrush}"
Visibility="Collapsed" />
Background="{StaticResource TreeChildLineBrush}" />
<Border
x:Name="ChildBranch"
Width="10"
Height="2"
Margin="8,0,0,0"
Width="8"
Height="1"
Margin="7,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Background="{StaticResource TreeChildLineBrush}"
Visibility="Collapsed" />
Background="{StaticResource TreeChildLineBrush}" />
</Grid>
<Border
Grid.Column="1"
Width="18"
Height="18"
Width="16"
Height="16"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="Transparent"
CornerRadius="4">
<Image
Width="14"
Height="14"
Width="13"
Height="13"
Source="{Binding Icon}"
Stretch="Uniform" />
</Border>
<StackPanel
<TextBlock
Grid.Column="2"
Margin="3,0,0,0"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="10.5"
Foreground="#888888"
Text="{Binding Index, StringFormat='[{0}] '}" />
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11.5"
FontWeight="SemiBold"
Text="{Binding Name}" />
</StackPanel>
FontFamily="{StaticResource UiFont}"
FontSize="11"
FontWeight="SemiBold"
Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<StackPanel
x:Name="NodeActions"
@@ -204,32 +260,6 @@
VerticalAlignment="Center"
Orientation="Horizontal"
Visibility="Collapsed">
<Button
Width="20"
Height="20"
Margin="1,0"
Background="White"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=TreeView}}"
CommandParameter="{Binding}"
Content="↑"
Cursor="Hand"
FontSize="10"
ToolTip="上移" />
<Button
Width="20"
Height="20"
Margin="1,0"
Background="White"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=TreeView}}"
CommandParameter="{Binding}"
Content="↓"
Cursor="Hand"
FontSize="10"
ToolTip="下移" />
<Button
Width="20"
Height="20"
@@ -248,12 +278,13 @@
<DataTemplate.Triggers>
<Trigger SourceName="NodeRoot" Property="IsMouseOver" Value="True">
<Setter TargetName="NodeActions" Property="Visibility" Value="Visible" />
<Setter TargetName="NodeCard" Property="Background" Value="#F3F7FB" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#D7E4F1" />
<Setter TargetName="NodeCard" Property="Background" Value="#F6FAFD" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#DFEAF3" />
</Trigger>
<DataTrigger Binding="{Binding IsPositionChild}" Value="True">
<Setter TargetName="ChildStem" Property="Visibility" Value="Visible" />
<Setter TargetName="ChildBranch" Property="Visibility" Value="Visible" />
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TreeViewItem}}" Value="True">
<Setter TargetName="NodeActions" Property="Visibility" Value="Visible" />
<Setter TargetName="NodeCard" Property="Background" Value="#DCEEFF" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#71A9DB" />
</DataTrigger>
</DataTemplate.Triggers>
</HierarchicalDataTemplate>
@@ -269,225 +300,188 @@
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<StackPanel Visibility="{Binding SelectedNode, Converter={StaticResource NullToVisibilityConverter}}">
<TextBlock Style="{StaticResource EditorTitle}" Text="节点属性" />
<Grid Grid.Column="2">
<ScrollViewer
x:Name="NodePropertyEditor"
VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<StackPanel Visibility="{Binding SelectedNode, Converter={StaticResource NullToVisibilityConverter}}">
<TextBlock Style="{StaticResource EditorTitle}" Text="节点属性" />
<TextBlock Style="{StaticResource LabelStyle}" Text="名称" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Name, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="名称" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Name, UpdateSourceTrigger=PropertyChanged}" />
<UniformGrid Margin="0,0,0,8" Columns="2">
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="索引" />
<TextBox Style="{StaticResource EditorBox}" IsReadOnly="True" Text="{Binding SelectedNode.Index, Mode=OneWay}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="类型" />
<TextBox Style="{StaticResource EditorBox}" IsReadOnly="True" Text="{Binding SelectedNode.NodeTypeDisplay, Mode=OneWay}" />
</StackPanel>
</UniformGrid>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="运动参数"
Visibility="{Binding SelectedNode.IsMotionSnapshotNode, Converter={StaticResource BoolToVisibilityConverter}}">
<UniformGrid Margin="8,8,8,6" Columns="2">
<UniformGrid Margin="0,0,0,8" Columns="2">
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="XM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.XM, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="索引" />
<Label Style="{StaticResource DisplayValueLabel}" Content="{Binding SelectedNode.Index, Mode=OneWay}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="YM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.YM, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="ZT" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZT, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="ZD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="TiltD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.TiltD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="Dist" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Dist, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="类型" />
<Label Style="{StaticResource DisplayValueLabel}" Content="{Binding SelectedNode.NodeTypeDisplay, Mode=OneWay}" />
</StackPanel>
</UniformGrid>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="射线源"
Visibility="{Binding SelectedNode.IsReferencePoint, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电流 (uA)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Current, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="运动参数"
Visibility="{Binding SelectedNode.IsMotionSnapshotNode, Converter={StaticResource BoolToVisibilityConverter}}">
<UniformGrid Margin="8,8,8,6" Columns="2">
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="XM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.XM, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="YM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.YM, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="ZT" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZT, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="ZD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="TiltD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.TiltD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="Dist" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Dist, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</UniformGrid>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="保存参数"
Visibility="{Binding SelectedNode.IsSaveNode, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="功率 (W)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Power, UpdateSourceTrigger=LostFocus}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器已连接" IsChecked="{Binding SelectedNode.DetectorConnected}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器正在采集" IsChecked="{Binding SelectedNode.DetectorAcquiring}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="帧率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FrameRate, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="分辨率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Resolution, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="射线源"
Visibility="{Binding SelectedNode.IsReferencePoint, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电流 (uA)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Current, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="保存参数"
Visibility="{Binding SelectedNode.IsSaveNodeWithImage, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="功率 (W)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Power, UpdateSourceTrigger=LostFocus}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器已连接" IsChecked="{Binding SelectedNode.DetectorConnected}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器正在采集" IsChecked="{Binding SelectedNode.DetectorAcquiring}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="帧率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FrameRate, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="分辨率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Resolution, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="图像文件" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ImageFileName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="保存参数"
Visibility="{Binding SelectedNode.IsSaveNode, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="功率 (W)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Power, UpdateSourceTrigger=LostFocus}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器已连接" IsChecked="{Binding SelectedNode.DetectorConnected}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器正在采集" IsChecked="{Binding SelectedNode.DetectorAcquiring}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="帧率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FrameRate, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="分辨率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Resolution, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="检测模块"
Visibility="{Binding SelectedNode.IsInspectionModule, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="Pipeline 名称" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.PipelineName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="保存参数"
Visibility="{Binding SelectedNode.IsSaveNodeWithImage, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="功率 (W)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Power, UpdateSourceTrigger=LostFocus}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器已连接" IsChecked="{Binding SelectedNode.DetectorConnected}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器正在采集" IsChecked="{Binding SelectedNode.DetectorAcquiring}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="帧率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FrameRate, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="分辨率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Resolution, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="图像文件" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ImageFileName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="检测标记"
Visibility="{Binding SelectedNode.IsInspectionMarker, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="标记类型" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerType, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="MarkerX" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerX, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="MarkerY" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerY, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="检测标记"
Visibility="{Binding SelectedNode.IsInspectionMarker, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="标记类型" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerType, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="MarkerX" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerX, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="MarkerY" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerY, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="消息弹窗"
Visibility="{Binding SelectedNode.IsPauseDialog, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="标题" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DialogTitle, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="消息内容" />
<TextBox
MinHeight="68"
Margin="0,0,0,8"
Padding="8,6"
AcceptsReturn="True"
BorderBrush="#CFCFCF"
BorderThickness="1"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding SelectedNode.DialogMessage, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="消息弹窗"
Visibility="{Binding SelectedNode.IsPauseDialog, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="标题" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DialogTitle, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="消息内容" />
<TextBox
MinHeight="68"
Margin="0,0,0,8"
Padding="8,6"
AcceptsReturn="True"
BorderBrush="#CFCFCF"
BorderThickness="1"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding SelectedNode.DialogMessage, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="等待参数"
Visibility="{Binding SelectedNode.IsWaitDelay, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="10,8,10,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="延时 (ms)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DelayMilliseconds, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
</StackPanel>
<Border
Padding="12"
Background="#FAFAFA"
BorderBrush="#E6E6E6"
BorderThickness="1"
CornerRadius="6"
Visibility="{Binding SelectedNode, Converter={StaticResource NullToVisibilityConverter}, ConverterParameter=Invert}">
<StackPanel>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="未选择节点"
TextWrapping="Wrap" />
<TextBlock
Margin="0,6,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="从左侧树中选择一个节点后,这里会显示可编辑的参数。"
TextWrapping="Wrap" />
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="等待参数"
Visibility="{Binding SelectedNode.IsWaitDelay, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="10,8,10,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="延时 (ms)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DelayMilliseconds, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
</ScrollViewer>
<Rectangle
Grid.Column="3"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<Grid Grid.Column="4">
<views:PipelineEditorView
x:Name="InspectionModulePipelineEditor"
Margin="0"
Visibility="{Binding EditorVisibility}" />
<Border
x:Name="InspectionModulePipelineEmptyState"
x:Name="NodePropertyEmptyState"
Margin="12"
Padding="16"
Background="#FAFAFA"
BorderBrush="#E6E6E6"
BorderThickness="1"
CornerRadius="6"
Visibility="{Binding EmptyStateVisibility}">
CornerRadius="6">
<StackPanel>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="未选择检测模块"
Text="未选择节点"
TextWrapping="Wrap" />
<TextBlock
Margin="0,6,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="请选择一个检测模块节点后,这里拖拽算子并配置参数。"
Text="从左侧树中选择一个节点后,这里会显示对应的参数或检测流程。"
TextWrapping="Wrap" />
</StackPanel>
</Border>
+336 -1
View File
@@ -1,9 +1,13 @@
using Prism.Ioc;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using XP.Common.Logging.Interfaces;
using XplorePlane.Services;
using XplorePlane.ViewModels.Cnc;
@@ -15,7 +19,17 @@ namespace XplorePlane.Views.Cnc
/// </summary>
public partial class CncPageView : UserControl
{
private static readonly Brush SelectedNodeBackground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E7F0F7"));
private static readonly Brush SelectedNodeBorder = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CB9D1"));
private static readonly Brush HoverNodeBackground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F6FAFC"));
private static readonly Brush HoverNodeBorder = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#D7E4EE"));
private static readonly Brush SelectedNodeForeground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1F4E79"));
private static readonly Brush DefaultNodeForeground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#202020"));
private static readonly Brush TransparentBrush = Brushes.Transparent;
private CncInspectionModulePipelineViewModel _inspectionModulePipelineViewModel;
private readonly Dictionary<TextBox, Label> _textDisplayLabels = new();
private readonly Dictionary<CheckBox, Label> _checkDisplayLabels = new();
public CncPageView()
{
@@ -55,12 +69,18 @@ namespace XplorePlane.Views.Cnc
logger);
InspectionModulePipelineEditor.DataContext = _inspectionModulePipelineViewModel;
InspectionModulePipelineEmptyState.DataContext = _inspectionModulePipelineViewModel;
}
catch (Exception)
{
// keep page usable even if pipeline editor host setup fails
}
CncTreeView.ContextMenuOpening -= CncTreeView_ContextMenuOpening;
CncTreeView.ContextMenuOpening += CncTreeView_ContextMenuOpening;
CncTreeView.LayoutUpdated -= CncTreeView_LayoutUpdated;
CncTreeView.LayoutUpdated += CncTreeView_LayoutUpdated;
UpdateNodeVisualState();
UpdatePropertyEditorState();
}
private void CncTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
@@ -69,6 +89,295 @@ namespace XplorePlane.Views.Cnc
{
viewModel.SelectedNode = e.NewValue as CncNodeViewModel;
}
UpdateNodeVisualState();
UpdatePropertyEditorState();
}
private void CncTreeView_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Delete || DataContext is not CncEditorViewModel viewModel)
return;
if (!viewModel.DeleteNodeCommand.CanExecute())
return;
viewModel.DeleteNodeCommand.Execute();
e.Handled = true;
}
private void CncTreeView_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
if (DataContext is not CncEditorViewModel viewModel)
{
return;
}
var position = Mouse.GetPosition(CncTreeView);
var hit = VisualTreeHelper.HitTest(CncTreeView, position);
var treeViewItem = FindAncestor<TreeViewItem>(hit?.VisualHit);
if (treeViewItem?.DataContext is not CncNodeViewModel nodeVm)
{
CncTreeView.ContextMenu = null;
return;
}
viewModel.SelectedNode = nodeVm;
UpdateNodeVisualState();
UpdatePropertyEditorState();
CncTreeView.ContextMenu = new ContextMenu
{
Items =
{
new MenuItem
{
Header = "\u5728\u4E0A\u65B9\u63D2\u5165\u4F4D\u7F6E",
Command = viewModel.PrepareInsertAboveCommand,
CommandParameter = nodeVm
},
new MenuItem
{
Header = "\u5728\u4E0B\u65B9\u63D2\u5165\u4F4D\u7F6E",
Command = viewModel.PrepareInsertBelowCommand,
CommandParameter = nodeVm
}
}
};
}
private void CncTreeView_LayoutUpdated(object sender, EventArgs e)
{
HideInlineDeleteButtons();
UpdateNodeVisualState();
UpdatePropertyEditorState();
}
private void HideInlineDeleteButtons()
{
foreach (var button in FindVisualDescendants<Button>(CncTreeView))
{
if (button.ToolTip is string)
{
button.Visibility = Visibility.Collapsed;
button.IsHitTestVisible = false;
}
}
}
private void UpdateNodeVisualState()
{
foreach (var item in FindVisualDescendants<TreeViewItem>(CncTreeView))
{
if (item.DataContext is not CncNodeViewModel)
{
continue;
}
var card = FindNodeCard(item);
if (card == null)
{
continue;
}
if (item.IsSelected)
{
card.Background = SelectedNodeBackground;
card.BorderBrush = SelectedNodeBorder;
ApplyNodeTextForeground(card, SelectedNodeForeground);
}
else if (card.IsMouseOver)
{
card.Background = HoverNodeBackground;
card.BorderBrush = HoverNodeBorder;
ApplyNodeTextForeground(card, DefaultNodeForeground);
}
else
{
card.Background = TransparentBrush;
card.BorderBrush = TransparentBrush;
ApplyNodeTextForeground(card, DefaultNodeForeground);
}
}
}
private void UpdatePropertyEditorState()
{
if (DataContext is not CncEditorViewModel viewModel)
{
return;
}
var propertyEditorRoot = FindPropertyEditorRoot();
if (propertyEditorRoot == null)
{
return;
}
bool isReadOnlyNode = viewModel.SelectedNode?.IsReadOnlyNodeProperties == true;
bool showNodeProperties = viewModel.SelectedNode != null && !viewModel.SelectedNode.IsInspectionModule;
NodePropertyEditor.Visibility = showNodeProperties ? Visibility.Visible : Visibility.Collapsed;
NodePropertyEmptyState.Visibility = viewModel.SelectedNode == null ? Visibility.Visible : Visibility.Collapsed;
foreach (var textBox in FindVisualDescendants<TextBox>(propertyEditorRoot))
{
var bindingExpression = textBox.GetBindingExpression(TextBox.TextProperty);
string bindingPath = bindingExpression?.ParentBinding?.Path?.Path ?? string.Empty;
if (string.IsNullOrWhiteSpace(bindingPath))
{
continue;
}
bool alwaysDisplay = bindingPath is "SelectedNode.Index" or "SelectedNode.NodeTypeDisplay";
var label = EnsureTextDisplayLabel(textBox, bindingPath);
bool showLabel = alwaysDisplay || isReadOnlyNode;
textBox.IsReadOnly = showLabel;
textBox.Visibility = showLabel ? Visibility.Collapsed : Visibility.Visible;
label.Visibility = showLabel ? Visibility.Visible : Visibility.Collapsed;
}
foreach (var checkBox in FindVisualDescendants<CheckBox>(propertyEditorRoot))
{
var bindingExpression = checkBox.GetBindingExpression(ToggleButton.IsCheckedProperty);
string bindingPath = bindingExpression?.ParentBinding?.Path?.Path ?? string.Empty;
if (string.IsNullOrWhiteSpace(bindingPath))
{
continue;
}
var label = EnsureCheckDisplayLabel(checkBox, bindingPath);
checkBox.IsEnabled = !isReadOnlyNode;
checkBox.Visibility = isReadOnlyNode ? Visibility.Collapsed : Visibility.Visible;
label.Visibility = isReadOnlyNode ? Visibility.Visible : Visibility.Collapsed;
}
}
private Label EnsureTextDisplayLabel(TextBox textBox, string bindingPath)
{
if (_textDisplayLabels.TryGetValue(textBox, out var existingLabel))
{
return existingLabel;
}
var label = new Label
{
Style = TryFindResource("DisplayValueLabel") as Style,
Visibility = Visibility.Collapsed
};
label.SetBinding(ContentProperty, new Binding(bindingPath));
InsertCompanionControl(textBox, label);
_textDisplayLabels[textBox] = label;
return label;
}
private Label EnsureCheckDisplayLabel(CheckBox checkBox, string bindingPath)
{
if (_checkDisplayLabels.TryGetValue(checkBox, out var existingLabel))
{
return existingLabel;
}
var label = new Label
{
Style = TryFindResource("DisplayValueLabel") as Style,
Visibility = Visibility.Collapsed
};
label.SetBinding(ContentProperty, new Binding(bindingPath)
{
Converter = TryFindResource("BoolToDisplayTextConverter") as IValueConverter
});
InsertCompanionControl(checkBox, label);
_checkDisplayLabels[checkBox] = label;
return label;
}
private static void InsertCompanionControl(Control sourceControl, Control companionControl)
{
if (VisualTreeHelper.GetParent(sourceControl) is not Panel panel)
{
return;
}
if (panel.Children.Contains(companionControl))
{
return;
}
int index = panel.Children.IndexOf(sourceControl);
panel.Children.Insert(index + 1, companionControl);
}
private static void ApplyNodeTextForeground(Border card, Brush foreground)
{
foreach (var textBlock in FindVisualDescendants<TextBlock>(card))
{
if (textBlock.Visibility == Visibility.Visible)
{
textBlock.Foreground = foreground;
}
}
}
private DependencyObject FindPropertyEditorRoot()
{
return NodePropertyEditor;
}
private static Border FindNodeCard(DependencyObject root)
{
foreach (var border in FindVisualDescendants<Border>(root))
{
if (border.DataContext is CncNodeViewModel && border.CornerRadius.TopLeft == 4 && border.BorderThickness.Left == 1)
{
return border;
}
}
return null;
}
private static T FindAncestor<T>(DependencyObject dependencyObject) where T : DependencyObject
{
var current = dependencyObject;
while (current != null)
{
if (current is T match)
{
return match;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private static System.Collections.Generic.IEnumerable<T> FindVisualDescendants<T>(DependencyObject root) where T : DependencyObject
{
if (root == null)
{
yield break;
}
int childCount = VisualTreeHelper.GetChildrenCount(root);
for (int i = 0; i < childCount; i++)
{
var child = VisualTreeHelper.GetChild(root, i);
if (child is T typed)
{
yield return typed;
}
foreach (var descendant in FindVisualDescendants<T>(child))
{
yield return descendant;
}
}
}
}
@@ -91,4 +400,30 @@ namespace XplorePlane.Views.Cnc
throw new NotSupportedException();
}
}
public class InverseBooleanToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is true ? Visibility.Collapsed : Visibility.Visible;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
public class BoolToDisplayTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is true ? "\u662F" : "\u5426";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace XplorePlane.Views
{
/// <summary>返回输入值的一半,用于十字线居中定位</summary>
public class HalfValueConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is double d ? d / 2.0 : 0.0;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
}
@@ -4,7 +4,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
d:DesignHeight="700"
d:DesignWidth="350"
mc:Ignorable="d">
@@ -13,13 +12,27 @@
<SolidColorBrush x:Key="PanelBg" Color="White" />
<SolidColorBrush x:Key="PanelBorder" Color="#CDCBCB" />
<SolidColorBrush x:Key="SeparatorBrush" Color="#E0E0E0" />
<SolidColorBrush x:Key="AccentBlue" Color="#E3F0FF" />
<SolidColorBrush x:Key="AccentBlue" Color="#D9ECFF" />
<SolidColorBrush x:Key="DisabledNodeBg" Color="#F3F3F3" />
<SolidColorBrush x:Key="DisabledNodeLine" Color="#B9B9B9" />
<SolidColorBrush x:Key="DisabledNodeText" Color="#8A8A8A" />
<FontFamily x:Key="CsdFont">Microsoft YaHei UI</FontFamily>
<Style x:Key="PipelineNodeItemStyle" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Focusable" Value="False" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource AccentBlue}" />
<Setter Property="BorderBrush" Value="#5B9BD5" />
<Setter Property="BorderThickness" Value="1" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ToolbarBtn" TargetType="Button">
@@ -43,7 +56,7 @@
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="2*" MinHeight="180" />
<RowDefinition Height="4*" MinHeight="180" />
<RowDefinition Height="Auto" />
<RowDefinition Height="2*" MinHeight="80" />
<RowDefinition Height="Auto" />
@@ -56,30 +69,48 @@
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,1,0,1">
<Grid>
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal">
<Button
Command="{Binding NewPipelineCommand}"
Content="新建"
Style="{StaticResource ToolbarBtn}"
ToolTip="新建流水线" />
<Button
Command="{Binding SavePipelineCommand}"
Content="保存"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存当前检测模块流水线" />
<Button
Width="60"
Command="{Binding SaveAsPipelineCommand}"
Content="另存为"
Style="{StaticResource ToolbarBtn}"
ToolTip="导出当前检测模块流水线" />
<Button
Width="52"
Command="{Binding LoadPipelineCommand}"
Content="加载"
Style="{StaticResource ToolbarBtn}"
ToolTip="将流水线模板加载到当前检测模块" />
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel
Grid.Row="0"
Orientation="Horizontal">
<Button
Command="{Binding NewPipelineCommand}"
Content="新建"
Style="{StaticResource ToolbarBtn}"
ToolTip="新建流水线" />
<Button
Command="{Binding SavePipelineCommand}"
Content="保存"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存当前流水线" />
<Button
Width="60"
Command="{Binding SaveAsPipelineCommand}"
Content="另存为"
Style="{StaticResource ToolbarBtn}"
ToolTip="另存当前流水线" />
<Button
Width="52"
Command="{Binding LoadPipelineCommand}"
Content="加载"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载流水线" />
</StackPanel>
<TextBlock
Grid.Row="1"
Margin="2,4,2,0"
VerticalAlignment="Center"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
FontWeight="SemiBold"
Foreground="#333333"
Text="{Binding PipelineFileDisplayName}"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding PipelineFileDisplayName}" />
</Grid>
</Border>
@@ -90,117 +121,108 @@
BorderThickness="0"
ItemContainerStyle="{StaticResource PipelineNodeItemStyle}"
ItemsSource="{Binding PipelineNodes}"
KeyboardNavigation.TabNavigation="Continue"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid x:Name="NodeRoot" MinHeight="48">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="44" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border
x:Name="NodeContainer"
Margin="2"
Padding="2"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="1"
CornerRadius="3">
<Grid x:Name="NodeRoot" MinHeight="48">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="44" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Line
x:Name="TopLine"
HorizontalAlignment="Center"
VerticalAlignment="Top"
Stroke="#5B9BD5"
StrokeThickness="2"
X1="0"
X2="0"
Y1="0"
Y2="10" />
<Line
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Stroke="#5B9BD5"
StrokeThickness="2"
X1="0"
X2="0"
Y1="0"
Y2="14" />
<Line
x:Name="TopLine"
HorizontalAlignment="Center"
VerticalAlignment="Top"
Stroke="#5B9BD5"
StrokeThickness="2"
X1="0"
X2="0"
Y1="0"
Y2="10" />
<Line
x:Name="BottomLine"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Stroke="#5B9BD5"
StrokeThickness="2"
X1="0"
X2="0"
Y1="0"
Y2="14" />
<Border
Grid.Column="0"
Width="28"
Height="28"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="#E8F0FE"
BorderBrush="#5B9BD5"
BorderThickness="1.5"
CornerRadius="4">
<TextBlock
<Border
x:Name="IconBorder"
Grid.Column="0"
Width="28"
Height="28"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="13"
Text="{Binding IconPath}" />
</Border>
Background="#E8F0FE"
BorderBrush="#5B9BD5"
BorderThickness="1.5"
CornerRadius="4">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="13"
Text="{Binding IconPath}" />
</Border>
<TextBlock
Grid.Column="1"
Margin="6,0,0,0"
VerticalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="12"
Text="{Binding DisplayName}" />
<StackPanel
x:Name="NodeActions"
Grid.Column="2"
Margin="0,0,4,0"
VerticalAlignment="Center"
Orientation="Horizontal"
Visibility="Collapsed">
<Button
Width="22"
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content="上"
Cursor="Hand"
FontSize="10"
ToolTip="上移" />
<Button
Width="22"
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content="下"
Cursor="Hand"
FontSize="10"
ToolTip="下移" />
<Button
Width="22"
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#E05050"
BorderThickness="1"
Command="{Binding DataContext.RemoveOperatorCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content="删"
Cursor="Hand"
FontSize="10"
ToolTip="删除" />
</StackPanel>
</Grid>
<StackPanel Grid.Column="1" Margin="6,0,0,0" VerticalAlignment="Center">
<TextBlock
x:Name="NodeTitle"
FontFamily="Microsoft YaHei UI"
FontSize="12"
Text="{Binding DisplayName}" />
<TextBlock
x:Name="NodeState"
Margin="0,2,0,0"
FontFamily="Microsoft YaHei UI"
FontSize="10"
Foreground="#6E6E6E"
Text="已启用" />
</StackPanel>
</Grid>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Order}" Value="0">
<Setter TargetName="TopLine" Property="Visibility" Value="Collapsed" />
</DataTrigger>
<Trigger SourceName="NodeRoot" Property="IsMouseOver" Value="True">
<Setter TargetName="NodeActions" Property="Visibility" Value="Visible" />
</Trigger>
<DataTrigger Binding="{Binding IsEnabled}" Value="False">
<Setter TargetName="NodeContainer" Property="Background" Value="{StaticResource DisabledNodeBg}" />
<Setter TargetName="NodeContainer" Property="Opacity" Value="0.78" />
<Setter TargetName="TopLine" Property="Stroke" Value="{StaticResource DisabledNodeLine}" />
<Setter TargetName="BottomLine" Property="Stroke" Value="{StaticResource DisabledNodeLine}" />
<Setter TargetName="IconBorder" Property="BorderBrush" Value="{StaticResource DisabledNodeLine}" />
<Setter TargetName="IconBorder" Property="Background" Value="#ECECEC" />
<Setter TargetName="NodeTitle" Property="Foreground" Value="{StaticResource DisabledNodeText}" />
<Setter TargetName="NodeState" Property="Text" Value="已停用" />
<Setter TargetName="NodeState" Property="Foreground" Value="#9A6767" />
</DataTrigger>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ListBoxItem}, Path=IsSelected}" Value="True">
<Setter TargetName="NodeContainer" Property="Background" Value="#D9ECFF" />
<Setter TargetName="NodeContainer" Property="BorderBrush" Value="#5B9BD5" />
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsEnabled}" Value="False" />
<Condition Binding="{Binding RelativeSource={RelativeSource AncestorType=ListBoxItem}, Path=IsSelected}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter TargetName="NodeContainer" Property="Background" Value="#E6EEF7" />
<Setter TargetName="NodeContainer" Property="BorderBrush" Value="#7E9AB6" />
<Setter TargetName="NodeContainer" Property="Opacity" Value="1" />
</MultiDataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -224,6 +246,16 @@
Foreground="#555"
Text="参数配置" />
<ItemsControl ItemsSource="{Binding SelectedNode.Parameters}">
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,3">
@@ -239,6 +271,7 @@
Text="{Binding DisplayName}"
TextTrimming="CharacterEllipsis" />
<TextBox
x:Name="TextValueEditor"
Grid.Column="1"
Padding="4,2"
BorderBrush="#CDCBCB"
@@ -250,15 +283,56 @@
<Style TargetType="TextBox">
<Setter Property="BorderBrush" Value="#CDCBCB" />
<Setter Property="Background" Value="White" />
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsTextInput}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
<DataTrigger Binding="{Binding IsValueValid}" Value="False">
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="Background" Value="#FFF0F0" />
<Setter Property="BorderBrush" Value="#D9534F" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<ComboBox
Grid.Column="1"
MinHeight="24"
Padding="4,1"
BorderBrush="#CDCBCB"
BorderThickness="1"
FontFamily="Microsoft YaHei UI"
FontSize="11"
ItemsSource="{Binding Options}"
SelectedItem="{Binding SelectedOption, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<ComboBox.Style>
<Style TargetType="ComboBox">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasOptions}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</ComboBox.Style>
</ComboBox>
<CheckBox
Grid.Column="1"
VerticalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="11"
IsChecked="{Binding BoolValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<CheckBox.Style>
<Style TargetType="CheckBox">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsBool}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</CheckBox.Style>
</CheckBox>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -268,16 +342,38 @@
<Border
Grid.Row="4"
Padding="6,4"
Background="#F5F5F5"
BorderBrush="{StaticResource PanelBorder}"
Height="24"
Padding="6,0"
BorderThickness="0,1,0,0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#F5F5F5" />
<Setter Property="BorderBrush" Value="{StaticResource PanelBorder}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
<Setter Property="Background" Value="#FFF1F1" />
<Setter Property="BorderBrush" Value="#D9534F" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
Foreground="#555"
Text="{Binding StatusMessage, StringFormat='Status: {0}'}"
TextTrimming="CharacterEllipsis" />
TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#555" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
<Setter Property="Foreground" Value="#A12A2A" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</Grid>
</Border>
@@ -2,15 +2,27 @@ using Prism.Ioc;
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.ViewModels;
namespace XplorePlane.Views
{
public partial class PipelineEditorView : UserControl
{
private const string PipelineNodeDragFormat = "PipelineNodeDrag";
private readonly ILoggerService _logger;
private Point _dragStartPoint;
private bool _isInternalDragging;
private bool _suppressClickToggle;
private PipelineNodeViewModel _draggedNode;
public PipelineEditorView()
{
InitializeComponent();
@@ -35,50 +47,252 @@ namespace XplorePlane.Views
}
}
_logger?.Info("PipelineEditorView DataContext 类型={Type}",
DataContext?.GetType().Name);
PipelineListBox.AllowDrop = true;
PipelineListBox.Drop += OnOperatorDropped;
PipelineListBox.Focusable = true;
PipelineListBox.Drop -= OnListBoxDrop;
PipelineListBox.Drop += OnListBoxDrop;
PipelineListBox.DragOver -= OnDragOver;
PipelineListBox.DragOver += OnDragOver;
_logger?.Debug("PipelineEditorView 原生 Drop 目标已注册");
PipelineListBox.PreviewMouseLeftButtonDown -= OnPreviewMouseLeftButtonDown;
PipelineListBox.PreviewMouseLeftButtonDown += OnPreviewMouseLeftButtonDown;
PipelineListBox.PreviewMouseMove -= OnPreviewMouseMove;
PipelineListBox.PreviewMouseMove += OnPreviewMouseMove;
PipelineListBox.PreviewMouseLeftButtonUp -= OnPreviewMouseLeftButtonUp;
PipelineListBox.PreviewMouseLeftButtonUp += OnPreviewMouseLeftButtonUp;
PipelineListBox.PreviewKeyDown -= OnPreviewKeyDown;
PipelineListBox.PreviewKeyDown += OnPreviewKeyDown;
}
private void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_dragStartPoint = e.GetPosition(PipelineListBox);
_isInternalDragging = false;
_draggedNode = FindNodeFromOriginalSource(e.OriginalSource);
if (_draggedNode != null)
{
PipelineListBox.SelectedItem = _draggedNode;
PipelineListBox.Focus();
}
}
private void OnPreviewMouseMove(object sender, MouseEventArgs e)
{
if (e.LeftButton != MouseButtonState.Pressed || _draggedNode == null || IsInteractiveChild(e.OriginalSource))
return;
var position = e.GetPosition(PipelineListBox);
var delta = position - _dragStartPoint;
if (_isInternalDragging
|| (Math.Abs(delta.X) < SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(delta.Y) < SystemParameters.MinimumVerticalDragDistance))
{
return;
}
_isInternalDragging = true;
_suppressClickToggle = true;
var data = new DataObject(PipelineNodeDragFormat, _draggedNode);
DragDrop.DoDragDrop(PipelineListBox, data, DragDropEffects.Move);
Dispatcher.BeginInvoke(new Action(() =>
{
_suppressClickToggle = false;
ResetDragState();
}), DispatcherPriority.Background);
}
private void OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
var vm = DataContext as IPipelineEditorHostViewModel;
var clickedNode = FindNodeFromOriginalSource(e.OriginalSource);
if (_isInternalDragging)
{
ResetDragState();
return;
}
if (_suppressClickToggle)
{
_suppressClickToggle = false;
ResetDragState();
return;
}
if (vm == null || clickedNode == null || IsInteractiveChild(e.OriginalSource))
{
ResetDragState();
return;
}
PipelineListBox.SelectedItem = clickedNode;
PipelineListBox.Focus();
vm.ToggleOperatorEnabledCommand.Execute(clickedNode);
e.Handled = true;
ResetDragState();
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Delete || DataContext is not IPipelineEditorHostViewModel vm || vm.SelectedNode == null)
return;
vm.RemoveOperatorCommand.Execute(vm.SelectedNode);
e.Handled = true;
}
private void OnDragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(OperatorToolboxView.DragFormat)
? DragDropEffects.Copy
: DragDropEffects.None;
if (e.Data.GetDataPresent(OperatorToolboxView.DragFormat))
{
e.Effects = DragDropEffects.Copy;
}
else if (e.Data.GetDataPresent(PipelineNodeDragFormat))
{
e.Effects = DragDropEffects.Move;
}
else
{
e.Effects = DragDropEffects.None;
}
e.Handled = true;
}
private void OnOperatorDropped(object sender, DragEventArgs e)
private void OnListBoxDrop(object sender, DragEventArgs e)
{
if (DataContext is not IPipelineEditorHostViewModel vm)
{
_logger?.Warn("Drop 事件触发,但 DataContext 不是流水线宿主 ViewModel");
ResetDragState();
return;
}
if (!e.Data.GetDataPresent(OperatorToolboxView.DragFormat))
if (e.Data.GetDataPresent(OperatorToolboxView.DragFormat))
{
_logger?.Warn("Drop 事件触发,但数据中没有 {Format}", OperatorToolboxView.DragFormat);
return;
OnOperatorDropped(vm, e);
}
else if (e.Data.GetDataPresent(PipelineNodeDragFormat))
{
OnInternalNodeDropped(vm, e);
}
ResetDragState();
e.Handled = true;
}
private void OnOperatorDropped(IPipelineEditorHostViewModel vm, DragEventArgs e)
{
var operatorKey = e.Data.GetData(OperatorToolboxView.DragFormat) as string;
if (string.IsNullOrWhiteSpace(operatorKey))
{
_logger?.Warn("Drop 事件触发,但 OperatorKey 为空");
_logger?.Warn("Drop 触发,但 OperatorKey 为空");
return;
}
_logger?.Info("算子已放入流水线:{OperatorKey},当前节点数(执行前)={Count}",
operatorKey, vm.PipelineNodes.Count);
vm.AddOperatorCommand.Execute(operatorKey);
_logger?.Info("AddOperator 执行后节点数={Count}PipelineListBox.Items.Count={ItemsCount}",
vm.PipelineNodes.Count, PipelineListBox.Items.Count);
e.Handled = true;
}
private void OnInternalNodeDropped(IPipelineEditorHostViewModel vm, DragEventArgs e)
{
if (e.Data.GetData(PipelineNodeDragFormat) is not PipelineNodeViewModel draggedNode
|| !vm.PipelineNodes.Contains(draggedNode))
{
return;
}
var oldIndex = vm.PipelineNodes.IndexOf(draggedNode);
var insertionIndex = GetDropInsertionIndex(e.GetPosition(PipelineListBox), vm.PipelineNodes.Count);
var newIndex = insertionIndex > oldIndex ? insertionIndex - 1 : insertionIndex;
newIndex = Math.Max(0, Math.Min(newIndex, vm.PipelineNodes.Count - 1));
if (oldIndex == newIndex)
{
return;
}
vm.ReorderOperatorCommand.Execute(new PipelineReorderArgs
{
OldIndex = oldIndex,
NewIndex = newIndex
});
}
private int GetDropInsertionIndex(Point position, int itemCount)
{
var item = GetItemAtPosition(position);
if (item == null)
{
return itemCount;
}
var targetIndex = PipelineListBox.ItemContainerGenerator.IndexFromContainer(item);
if (targetIndex < 0)
{
return itemCount;
}
var itemTop = item.TranslatePoint(new Point(0, 0), PipelineListBox).Y;
var itemMid = itemTop + (item.ActualHeight / 2);
return position.Y > itemMid ? targetIndex + 1 : targetIndex;
}
private ListBoxItem GetItemAtPosition(Point position)
{
var element = PipelineListBox.InputHitTest(position) as DependencyObject;
while (element != null)
{
if (element is ListBoxItem item)
{
return item;
}
element = VisualTreeHelper.GetParent(element);
}
return null;
}
private PipelineNodeViewModel FindNodeFromOriginalSource(object originalSource)
{
var current = originalSource as DependencyObject;
while (current != null)
{
if (current is FrameworkElement element && element.DataContext is PipelineNodeViewModel node)
{
return node;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private static bool IsInteractiveChild(object originalSource)
{
var current = originalSource as DependencyObject;
while (current != null)
{
if (current is ListBoxItem)
{
return false;
}
if (current is ButtonBase || current is TextBoxBase || current is ScrollBar)
{
return true;
}
current = VisualTreeHelper.GetParent(current);
}
return false;
}
private void ResetDragState()
{
_isInternalDragging = false;
_draggedNode = null;
}
}
}
+48 -12
View File
@@ -146,6 +146,7 @@
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="中心十字线"
Command="{Binding ToggleCrosshairCommand}"
Size="Medium"
SmallImage="/Assets/Icons/crosshair.png"
Text="辅助线" />
@@ -400,6 +401,48 @@
Size="Large"
SmallImage="/Assets/Icons/spiral.png" />
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="测量工具">
<telerik:RadRibbonGroup.Variants>
<telerik:GroupVariant Priority="0" Variant="Large" />
</telerik:RadRibbonGroup.Variants>
<!-- 第一列: 点点距 + 点线距 -->
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Description="测量两点之间的距离"
telerik:ScreenTip.Title="点点距测量"
Command="{Binding PointDistanceMeasureCommand}"
Size="Medium"
SmallImage="/Assets/Icons/crosshair.png"
Text="点点距测量" />
<telerik:RadRibbonButton
telerik:ScreenTip.Description="测量点到直线的距离"
telerik:ScreenTip.Title="点线距测量"
Command="{Binding PointLineDistanceMeasureCommand}"
Size="Medium"
SmallImage="/Assets/Icons/mark.png"
Text="点线距测量" />
</StackPanel>
<!-- 第二列: 角度 + 通孔填锡率 -->
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Description="测量两条线之间的角度"
telerik:ScreenTip.Title="角度测量"
Command="{Binding AngleMeasureCommand}"
Size="Medium"
SmallImage="/Assets/Icons/dynamic-range.png"
Text="角度测量" />
<telerik:RadRibbonButton
telerik:ScreenTip.Description="测量通孔填锡率"
telerik:ScreenTip.Title="通孔填锡率测量"
Command="{Binding ThroughHoleFillRateMeasureCommand}"
Size="Medium"
SmallImage="/Assets/Icons/pores.png"
Text="通孔填锡率" />
</StackPanel>
</telerik:RadRibbonGroup>
<!--
<telerik:RadRibbonGroup Header="图像处理">
<telerik:RadRibbonGroup.Variants>
@@ -469,21 +512,14 @@
Grid.ColumnSpan="3"
Margin="0">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="NavColumn" Width="0" />
<ColumnDefinition Width="{Binding ViewportPanelWidth}" />
<ColumnDefinition Width="{Binding ImagePanelWidth}" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="350" />
</Grid.ColumnDefinitions>
<!-- 左侧: 计划导航 (默认隐藏,点击CNC AccountingNumberFormatButton显示) -->
<views:NavigationPanelView
x:Name="NavigationPanel"
Grid.Column="0"
Visibility="Collapsed" />
<!-- 中间: 2D Viewport -->
<Border
Grid.Column="1"
Grid.Column="0"
BorderBrush="#DDDDDD"
BorderThickness="1,0,1,0">
<views:ViewportPanelView />
@@ -491,16 +527,16 @@
<!-- 中间: 图像 -->
<Border
Grid.Column="2"
Grid.Column="1"
BorderBrush="#DDDDDD"
BorderThickness="0,0,1,0">
<views:ImagePanelView />
</Border>
<!-- 右侧: 属性面板 -->
<Grid Grid.Column="3">
<Grid Grid.Column="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250*" />
<ColumnDefinition Width="350*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -13,12 +13,5 @@ namespace XplorePlane.Views
InitializeComponent();
DataContext = viewModel;
}
private void AccountingNumberFormatButton_Click(object sender, RoutedEventArgs e)
{
bool show = NavigationPanel.Visibility != Visibility.Visible;
NavigationPanel.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
NavColumn.Width = show ? new GridLength(180) : new GridLength(0);
}
}
}
@@ -4,7 +4,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="600"
d:DesignHeight="424"
d:DesignWidth="400"
mc:Ignorable="d">
<Grid Background="#FFFFFF">
@@ -48,12 +48,15 @@
<Border
Grid.Row="2"
Background="#000000"
Padding="8,4">
<TextBlock FontSize="12">
Height="24"
Padding="8,0">
<TextBlock
VerticalAlignment="Center"
FontSize="12">
<Run Foreground="#FFFFFF" Text="{Binding CameraStatusText, Mode=OneWay}" />
<Run Text=" " />
<Run Foreground="#0078D4" Text="{Binding CameraPixelCoord, Mode=OneWay}" />
</TextBlock>
</Border>
</Grid>
</UserControl>
</UserControl>
+23 -9
View File
@@ -10,6 +10,7 @@
d:DesignHeight="400"
d:DesignWidth="600"
mc:Ignorable="d">
<UserControl.Resources />
<Grid Background="#FFFFFF">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -23,15 +24,28 @@
FontWeight="SemiBold" Foreground="#333333" Text="实时图像" />
</Border>
<!-- 图像显示区域,支持滚动、缩放和ROI -->
<roi:PolygonRoiCanvas Grid.Row="1"
ImageSource="{Binding ImageSource}"
Background="White" />
<!-- 图像显示区域 -->
<Grid Grid.Row="1">
<roi:PolygonRoiCanvas x:Name="RoiCanvas"
ImageSource="{Binding ImageSource}"
Background="White">
<roi:PolygonRoiCanvas.ContextMenu>
<ContextMenu>
<MenuItem Header="放大" Click="ZoomIn_Click" />
<MenuItem Header="缩小" Click="ZoomOut_Click" />
<MenuItem Header="适应窗口" Click="ResetView_Click" />
<Separator />
<MenuItem Header="保存原始图像" Click="SaveOriginalImage_Click" />
<MenuItem Header="保存结果图像" Click="SaveResultImage_Click" />
<Separator />
<MenuItem Header="清除所有绘制" Click="ClearAllMeasurements_Click" />
</ContextMenu>
</roi:PolygonRoiCanvas.ContextMenu>
</roi:PolygonRoiCanvas>
<!-- 十字线和测量功能已内置于 PolygonRoiCanvas -->
</Grid>
<!-- 图像信息栏 -->
<Border Grid.Row="2" Background="#F0F0F0" BorderBrush="#DDDDDD" BorderThickness="0,1,0,0">
<TextBlock Margin="4,2" FontSize="11" Foreground="#666666"
Text="{Binding ImageInfo}" />
</Border>
</Grid>
</UserControl>
@@ -1,4 +1,15 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Microsoft.Win32;
using Prism.Ioc;
using XP.ImageProcessing.RoiControl.Controls;
using XplorePlane.Events;
using XplorePlane.ViewModels;
namespace XplorePlane.Views
{
@@ -7,6 +18,137 @@ namespace XplorePlane.Views
public ViewportPanelView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
// 测量事件 → 更新状态栏
RoiCanvas.MeasureCompleted += (s, e) =>
{
if (e is MeasureCompletedEventArgs args && DataContext is ViewportPanelViewModel vm)
{
vm.MeasurementResult = $"{args.Distance:F2} px";
string typeLabel = args.MeasureType == "PointToLine" ? "点线距" : "点点距";
vm.ImageInfo = $"{typeLabel}: {args.Distance:F2} px | ({args.P1.X:F0},{args.P1.Y:F0}) → ({args.P2.X:F0},{args.P2.Y:F0}) | 共 {args.TotalCount} 条测量";
}
};
RoiCanvas.MeasureStatusChanged += (s, e) =>
{
if (e is MeasureStatusEventArgs args && DataContext is ViewportPanelViewModel vm)
vm.ImageInfo = args.Message;
};
// 十字辅助线:直接订阅 Prism 事件
try
{
var ea = ContainerLocator.Current?.Resolve<Prism.Events.IEventAggregator>();
ea?.GetEvent<ToggleCrosshairEvent>().Subscribe(() =>
{
RoiCanvas.ShowCrosshair = !RoiCanvas.ShowCrosshair;
}, Prism.Events.ThreadOption.UIThread);
// 测量模式:直接订阅 Prism 事件
ea?.GetEvent<MeasurementToolEvent>().Subscribe(mode =>
{
RoiCanvas.CurrentMeasureMode = mode switch
{
MeasurementToolMode.PointDistance => XP.ImageProcessing.RoiControl.Models.MeasureMode.PointDistance,
MeasurementToolMode.PointLineDistance => XP.ImageProcessing.RoiControl.Models.MeasureMode.PointToLine,
_ => XP.ImageProcessing.RoiControl.Models.MeasureMode.None
};
}, Prism.Events.ThreadOption.UIThread);
}
catch { }
}
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is INotifyPropertyChanged oldVm)
oldVm.PropertyChanged -= OnVmPropertyChanged;
if (e.NewValue is INotifyPropertyChanged newVm)
newVm.PropertyChanged += OnVmPropertyChanged;
}
private void OnVmPropertyChanged(object sender, PropertyChangedEventArgs e)
{
// 测量模式和十字线通过 Prism 事件直接驱动,不再依赖 PropertyChanged
}
#region
private void ZoomIn_Click(object sender, RoutedEventArgs e) => RoiCanvas.ZoomScale = Math.Min(10.0, RoiCanvas.ZoomScale * 1.2);
private void ZoomOut_Click(object sender, RoutedEventArgs e) => RoiCanvas.ZoomScale = Math.Max(0.1, RoiCanvas.ZoomScale / 1.2);
private void ResetView_Click(object sender, RoutedEventArgs e) => RoiCanvas.ResetView();
private void ClearAllMeasurements_Click(object sender, RoutedEventArgs e)
{
RoiCanvas.ClearMeasurements();
if (DataContext is ViewportPanelViewModel vm)
{
vm.ResetMeasurementState();
vm.ImageInfo = "已清除所有测量";
}
}
private void SaveOriginalImage_Click(object sender, RoutedEventArgs e)
{
if (DataContext is not ViewportPanelViewModel vm || vm.ImageSource is not BitmapSource bitmap)
{
MessageBox.Show("当前没有可保存的图像", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
SaveBitmapToFile(bitmap, "保存原始图像");
}
private void SaveResultImage_Click(object sender, RoutedEventArgs e)
{
var target = FindChildByName<Canvas>(RoiCanvas, "mainCanvas");
if (target == null)
{
MessageBox.Show("当前没有可保存的图像", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var width = (int)target.ActualWidth;
var height = (int)target.ActualHeight;
if (width == 0 || height == 0) return;
var rtb = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
rtb.Render(target);
SaveBitmapToFile(rtb, "保存结果图像");
}
private static void SaveBitmapToFile(BitmapSource bitmap, string title)
{
var dialog = new SaveFileDialog
{
Title = title,
Filter = "PNG 图像|*.png|BMP 图像|*.bmp|JPEG 图像|*.jpg",
DefaultExt = ".png"
};
if (dialog.ShowDialog() != true) return;
BitmapEncoder encoder = Path.GetExtension(dialog.FileName).ToLower() switch
{
".bmp" => new BmpBitmapEncoder(),
".jpg" or ".jpeg" => new JpegBitmapEncoder(),
_ => new PngBitmapEncoder()
};
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var fs = new FileStream(dialog.FileName, FileMode.Create);
encoder.Save(fs);
}
#endregion
private static T FindChildByName<T>(DependencyObject parent, string name) where T : FrameworkElement
{
int count = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T t && t.Name == name) return t;
var result = FindChildByName<T>(child, name);
if (result != null) return result;
}
return null;
}
}
}
}