Merge branch 'Develop/XP' into turbo-002-cnc
# Conflicts: # XplorePlane/App.xaml.cs # XplorePlane/ViewModels/Main/MainViewModel.cs # XplorePlane/ViewModels/Main/ViewportPanelViewModel.cs # XplorePlane/Views/Main/MainWindow.xaml # XplorePlane/Views/Main/ViewportPanelView.xaml
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -309,6 +309,23 @@ namespace XplorePlane
|
||||
{
|
||||
HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
//TryConnectCamera();
|
||||
|
||||
//// 通知 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);
|
||||
//}
|
||||
};
|
||||
|
||||
return shell;
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using System.Configuration;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using XplorePlane.Events;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
using XplorePlane.ViewModels.Cnc;
|
||||
using XplorePlane.Views;
|
||||
@@ -25,6 +26,7 @@ namespace XplorePlane.ViewModels
|
||||
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly IContainerProvider _containerProvider;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IMainViewportService _mainViewportService;
|
||||
private readonly CncEditorViewModel _cncEditorViewModel;
|
||||
private readonly CncPageView _cncPageView;
|
||||
@@ -49,7 +51,7 @@ namespace XplorePlane.ViewModels
|
||||
{
|
||||
_logger = logger?.ForModule<MainViewModel>() ?? throw new ArgumentNullException(nameof(logger));
|
||||
_containerProvider = containerProvider ?? throw new ArgumentNullException(nameof(containerProvider));
|
||||
ArgumentNullException.ThrowIfNull(eventAggregator);
|
||||
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
||||
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
||||
_cncEditorViewModel = _containerProvider.Resolve<CncEditorViewModel>();
|
||||
_cncPageView = new CncPageView { DataContext = _cncEditorViewModel };
|
||||
@@ -86,6 +88,13 @@ 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);
|
||||
OpenMotionDebugCommand = new DelegateCommand(ExecuteOpenMotionDebug);
|
||||
@@ -147,6 +156,12 @@ 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; }
|
||||
public DelegateCommand UseLiveDetectorSourceCommand { get; }
|
||||
@@ -421,6 +436,30 @@ namespace XplorePlane.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
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("角度测量功能已触发");
|
||||
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.Angle);
|
||||
}
|
||||
|
||||
private void ExecuteThroughHoleFillRateMeasure()
|
||||
{
|
||||
_logger.Info("通孔填锡率测量功能已触发");
|
||||
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.ThroughHoleFillRate);
|
||||
}
|
||||
|
||||
private void ExecuteOpenLanguageSwitcher()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,59 +1,160 @@
|
||||
using Prism.Commands;
|
||||
using Prism.Events;
|
||||
using Prism.Mvvm;
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Events;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
|
||||
namespace XplorePlane.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// 主界面实时图像 ViewModel,只绑定主界面视口仲裁后的显示状态。
|
||||
/// 主界面实时图像 ViewModel,统一承接显示状态和测量状态。
|
||||
/// </summary>
|
||||
public class ViewportPanelViewModel : BindableBase
|
||||
{
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly IMainViewportService _mainViewportService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
|
||||
private ImageSource _imageSource;
|
||||
private string _imageInfo = "等待探测器图像...";
|
||||
private MeasurementToolMode _currentMeasurementMode = MeasurementToolMode.None;
|
||||
private Point? _measurePoint1;
|
||||
private Point? _measurePoint2;
|
||||
private string _measurementResult;
|
||||
|
||||
public ViewportPanelViewModel(
|
||||
IMainViewportService mainViewportService,
|
||||
IEventAggregator eventAggregator,
|
||||
ILoggerService logger)
|
||||
{
|
||||
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
||||
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
||||
_logger = logger?.ForModule<ViewportPanelViewModel>() ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
CancelMeasurementCommand = new DelegateCommand(ExecuteCancelMeasurement);
|
||||
|
||||
_mainViewportService.StateChanged += OnMainViewportStateChanged;
|
||||
_eventAggregator.GetEvent<MeasurementToolEvent>()
|
||||
.Subscribe(OnMeasurementToolActivated, ThreadOption.UIThread);
|
||||
|
||||
UpdateFromState(updateInfo: true);
|
||||
}
|
||||
|
||||
public ImageSource ImageSource
|
||||
{
|
||||
get => _imageSource;
|
||||
set => SetProperty(ref _imageSource, value);
|
||||
}
|
||||
|
||||
private string _imageInfo = "等待探测器图像...";
|
||||
public string ImageInfo
|
||||
{
|
||||
get => _imageInfo;
|
||||
set => SetProperty(ref _imageInfo, value);
|
||||
}
|
||||
|
||||
public ViewportPanelViewModel(IMainViewportService mainViewportService, ILoggerService logger)
|
||||
public MeasurementToolMode CurrentMeasurementMode
|
||||
{
|
||||
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
||||
_logger = logger?.ForModule<ViewportPanelViewModel>();
|
||||
get => _currentMeasurementMode;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _currentMeasurementMode, value))
|
||||
{
|
||||
RaisePropertyChanged(nameof(IsMeasuring));
|
||||
RaisePropertyChanged(nameof(MeasurementModeText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_mainViewportService.StateChanged += OnMainViewportStateChanged;
|
||||
UpdateFromState();
|
||||
public bool IsMeasuring => CurrentMeasurementMode != MeasurementToolMode.None;
|
||||
|
||||
public string MeasurementModeText => CurrentMeasurementMode switch
|
||||
{
|
||||
MeasurementToolMode.PointDistance => "点点距测量 - 请在图像上点击第一个点",
|
||||
MeasurementToolMode.PointLineDistance => "点线距测量 - 请按工具提示继续操作",
|
||||
MeasurementToolMode.Angle => "角度测量 - 功能待接入",
|
||||
MeasurementToolMode.ThroughHoleFillRate => "通孔填锡率测量 - 功能待接入",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
public Point? MeasurePoint1
|
||||
{
|
||||
get => _measurePoint1;
|
||||
set => SetProperty(ref _measurePoint1, value);
|
||||
}
|
||||
|
||||
public Point? MeasurePoint2
|
||||
{
|
||||
get => _measurePoint2;
|
||||
set => SetProperty(ref _measurePoint2, value);
|
||||
}
|
||||
|
||||
public string MeasurementResult
|
||||
{
|
||||
get => _measurementResult;
|
||||
set => SetProperty(ref _measurementResult, value);
|
||||
}
|
||||
|
||||
public DelegateCommand CancelMeasurementCommand { get; }
|
||||
|
||||
public void ResetMeasurementState()
|
||||
{
|
||||
MeasurePoint1 = null;
|
||||
MeasurePoint2 = null;
|
||||
MeasurementResult = null;
|
||||
|
||||
if (CurrentMeasurementMode == MeasurementToolMode.None)
|
||||
{
|
||||
ImageInfo = _mainViewportService.CurrentDisplayInfo;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteCancelMeasurement()
|
||||
{
|
||||
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.None);
|
||||
ImageInfo = "测量已取消";
|
||||
}
|
||||
|
||||
private void OnMeasurementToolActivated(MeasurementToolMode mode)
|
||||
{
|
||||
CurrentMeasurementMode = mode;
|
||||
ResetMeasurementState();
|
||||
|
||||
if (mode == MeasurementToolMode.None)
|
||||
{
|
||||
ImageInfo = _mainViewportService.CurrentDisplayInfo;
|
||||
}
|
||||
else
|
||||
{
|
||||
ImageInfo = MeasurementModeText;
|
||||
}
|
||||
|
||||
_logger.Info("测量工具模式切换: {Mode}", mode);
|
||||
}
|
||||
|
||||
private void OnMainViewportStateChanged(object sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current?.Dispatcher?.BeginInvoke(new Action(UpdateFromState));
|
||||
Application.Current?.Dispatcher?.BeginInvoke(new Action(() => UpdateFromState(updateInfo: CurrentMeasurementMode == MeasurementToolMode.None)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Error(ex, "刷新主界面实时图像失败");
|
||||
_logger.Error(ex, "刷新主界面实时图像失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFromState()
|
||||
private void UpdateFromState(bool updateInfo)
|
||||
{
|
||||
ImageSource = _mainViewportService.CurrentDisplayImage;
|
||||
ImageInfo = _mainViewportService.CurrentDisplayInfo;
|
||||
|
||||
if (updateInfo)
|
||||
{
|
||||
ImageInfo = _mainViewportService.CurrentDisplayInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,7 @@
|
||||
<StackPanel>
|
||||
<telerik:RadRibbonButton
|
||||
telerik:ScreenTip.Title="中心十字线"
|
||||
Command="{Binding ToggleCrosshairCommand}"
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/crosshair.png"
|
||||
Text="辅助线" />
|
||||
@@ -192,7 +193,49 @@
|
||||
</StackPanel>
|
||||
</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:ScreenTip.Title="图像算子">
|
||||
<telerik:RadRibbonButton
|
||||
@@ -325,6 +368,7 @@
|
||||
Size="Large"
|
||||
SmallImage="/Assets/Icons/spiral.png" />
|
||||
</telerik:RadRibbonGroup>
|
||||
|
||||
</telerik:RadRibbonTab>
|
||||
<telerik:RadRibbonTab Header="设置">
|
||||
<telerik:RadRibbonGroup
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
d:DesignHeight="400"
|
||||
d:DesignWidth="600"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources />
|
||||
<Grid Background="#FFFFFF">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
@@ -27,11 +28,24 @@
|
||||
Text="实时图像" />
|
||||
</Border>
|
||||
|
||||
<roi:PolygonRoiCanvas
|
||||
Grid.Row="1"
|
||||
Background="White"
|
||||
ImageSource="{Binding ImageSource}" />
|
||||
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<roi:PolygonRoiCanvas
|
||||
x:Name="RoiCanvas"
|
||||
Background="White"
|
||||
ImageSource="{Binding ImageSource}">
|
||||
<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>
|
||||
</Grid>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user