直线卡尺拟合直线
本文是一套基于工业级“卡尺算法”开发的视觉检测工具。它克服了传统灰度检测在彩色背景下对比度不足的问题,通过多卡尺并行采样与彩色向量梯度算法,实现了高精度的直线边缘定位与角度测量。
1. 算法原理
- 离散采样(Caliper Sampling):在用户绘制的基准线段上,等间距布置
N个采样算子。每个算子沿着基准线的法线方向进行一维像素搜索。 -
彩色向量梯度(Color Vector Gradient):
-
传统算法:BGR -> Gray -> Gradient(灰度化会丢失颜色边界,如红绿交界处灰度值可能相同)。
-
本算法:
通过计算三通道梯度的欧氏距离,捕捉颜色空间中的细微变化。
-
-
高斯平滑与亚像素处理:对采样的一维信号进行一维高斯核卷积,消除噪声干扰,通过梯度极值点定位边缘。
-
Huber 鲁棒拟合:采用 cv2.DIST_HUBER 损失函数进行直线拟合。相比普通最小二乘法,它能自动降低离群(Outlier)点的权重,防止个别干扰点导致直线偏转。
2. 代码实现
caliper_core.py
import cv2 import numpy as np import math class LineCaliperCore: #使用灰度 # @staticmethod # def find_line(image, p1, p2, search_width=20, num_calipers=10, threshold=30, sigma=1.0, transition='all'): # if image is None or p1 is None or p2 is None: # return False, None, None, [], 0.0 # # gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image # p1, p2 = np.array(p1, dtype=np.float32), np.array(p2, dtype=np.float32) # line_vec = p2 - p1 # line_len = np.linalg.norm(line_vec) # if line_len < 1: return False, None, None, [], 0.0 # # u = line_vec / line_len # n = np.array([-u[1], u[0]]) # detected_points = [] # # for i in range(num_calipers): # t = i / (max(1, num_calipers - 1)) # center = p1 + t * line_vec # intensities, sample_coords = [], [] # search_range = np.arange(-search_width, search_width + 1) # # for d in search_range: # pt = center + d * n # x, y = int(round(pt[0])), int(round(pt[1])) # if 0 <= x < gray.shape[1] and 0 <= y < gray.shape[0]: # intensities.append(gray[y, x]) # sample_coords.append(pt) # # if len(intensities) < 5: continue # # # 1D 高斯平滑 # if sigma > 0.1: # k_size = int(sigma * 3) * 2 + 1 # intensities = cv2.GaussianBlur(np.array(intensities, dtype=np.float32).reshape(-1, 1), (k_size, 1), # sigma).flatten() # # # 梯度计算 # diffs = np.diff(intensities) # if transition == 'positive': # grads = np.where(diffs > 0, diffs, 0) # elif transition == 'negative': # grads = np.where(diffs < 0, np.abs(diffs), 0) # else: # grads = np.abs(diffs) # # if len(grads) == 0: continue # max_idx = np.argmax(grads) # if grads[max_idx] > threshold: # detected_points.append(sample_coords[max_idx]) # # if len(detected_points) >= 2: # pts_arr = np.array(detected_points, dtype=np.float32).reshape(-1, 1, 2) # # 使用 HUBER 拟合,增加抗离群点能力 # [vx, vy, x0, y0] = cv2.fitLine(pts_arr, cv2.DIST_HUBER, 0, 0.01, 0.01) # angle_deg = math.degrees(math.atan2(vy, vx)) # ext = line_len * 0.5 + 50 # res_p1 = (int(x0 - vx * ext), int(y0 - vy * ext)) # res_p2 = (int(x0 + vx * ext), int(y0 + vy * ext)) # return True, res_p1, res_p2, detected_points, float(angle_deg) # # return False, None, None, detected_points, 0.0 #使用彩色lab更符合人眼 @staticmethod def find_line(image, p1, p2, search_width=20, num_calipers=10, threshold=30, sigma=1.0, transition='all'): """ 基于彩色/三通道梯度的卡尺找边算法 """ # 1. 基础合法性检查 if image is None or p1 is None or p2 is None: return False, None, None, [], 0.0 # 转换为浮点型以保证计算梯度时的精度(防止溢出) img_float = image.astype(np.float32) p1, p2 = np.array(p1, dtype=np.float32), np.array(p2, dtype=np.float32) line_vec = p2 - p1 line_len = np.linalg.norm(line_vec) if line_len < 1: return False, None, None, [], 0.0 # 方向向量与法向量 u = line_vec / line_len n = np.array([-u[1], u[0]]) detected_points = [] # 2. 遍历卡尺 for i in range(num_calipers): t = i / (max(1, num_calipers - 1)) center = p1 + t * line_vec intensities_b, intensities_g, intensities_r = [], [], [] sample_coords = [] search_range = np.arange(-search_width, search_width + 1) # 沿法线方向采样 for d in search_range: pt = center + d * n x, y = int(round(pt[0])), int(round(pt[1])) # 边界检查 if 0 <= x < image.shape[1] and 0 <= y < image.shape[0]: b, g, r = img_float[y, x] intensities_b.append(b) intensities_g.append(g) intensities_r.append(r) sample_coords.append(pt) if len(intensities_b) < 5: continue # 定义平滑函数 def smooth(data): if sigma > 0.1: # 确保核大小为奇数 k_size = int(sigma * 3) * 2 + 1 return cv2.GaussianBlur(np.array(data, dtype=np.float32).reshape(-1, 1), (k_size, 1), sigma).flatten() return np.array(data) # 对三个通道分别平滑 ib = smooth(intensities_b) ig = smooth(intensities_g) ir = smooth(intensities_r) # 3. 计算彩色梯度 (Vector Gradient) db, dg, dr = np.diff(ib), np.diff(ig), np.diff(ir) # 使用欧氏距离融合三通道梯度:这样即使亮度不变,只要颜色变了也能检测到 grads = np.sqrt(db ** 2 + dg ** 2 + dr ** 2) # 极性过滤 (基于总体亮度趋势) if transition != 'all': total_diff = db + dg + dr if transition == 'positive': grads[total_diff < 0] = 0 elif transition == 'negative': grads[total_diff > 0] = 0 if len(grads) == 0: continue # 找到梯度最大的点 max_idx = np.argmax(grads) if grads[max_idx] > threshold: detected_points.append(sample_coords[max_idx]) # 4. 直线拟合 (关键补全) if len(detected_points) >= 2: try: pts_arr = np.array(detected_points, dtype=np.float32).reshape(-1, 1, 2) # 使用 HUBER 鲁棒拟合,减少离群点干扰 [vx, vy, x0, y0] = cv2.fitLine(pts_arr, cv2.DIST_HUBER, 0, 0.01, 0.01) # 计算角度 angle_deg = math.degrees(math.atan2(vy, vx)) # 计算用于显示的延长线端点 (两端各延长一段距离) ext = line_len * 0.5 + 20 res_p1 = (int(x0 - vx * ext), int(y0 - vy * ext)) res_p2 = (int(x0 + vx * ext), int(y0 + vy * ext)) return True, res_p1, res_p2, detected_points, float(angle_deg) except Exception as e: print(f"Fitting error: {e}") return False, None, None, detected_points, 0.0 return False, None, None, detected_points, 0.0 @staticmethod def get_search_box(p1, p2, search_width): if p1 is None or p2 is None: return [] p1_arr, p2_arr = np.array(p1), np.array(p2) line_vec = p2_arr - p1_arr line_len = np.linalg.norm(line_vec) if line_len < 1: return [] u = line_vec / line_len n = np.array([-u[1], u[0]]) return [(p1_arr + n * search_width).astype(int), (p1_arr - n * search_width).astype(int), (p2_arr - n * search_width).astype(int), (p2_arr + n * search_width).astype(int)]
caliper_gui.py
import sys import cv2 import math import numpy as np from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSlider, QFileDialog, QColorDialog, QSpinBox, QGroupBox, QComboBox) from PyQt5.QtGui import QImage, QPixmap, QColor from PyQt5.QtCore import Qt from caliper_core import LineCaliperCore class CaliperApp(QMainWindow): ROTATION_STEP = 2 def __init__(self): super().__init__() self.raw_image = None self.p1, self.p2 = None, None self.scale_ratio, self.offset_x, self.offset_y = 1.0, 0, 0 self.mode = "none" # 视觉样式缓存 self.line_color = (0, 255, 0) # BGR self.pt_color = (0, 0, 255) self.detected_pts = [] self.fitted_line = None self.init_ui() def init_ui(self): self.setWindowTitle("全功能直线卡尺") self.setGeometry(50, 50, 1300, 880) main_layout = QHBoxLayout() left_panel, right_panel = QVBoxLayout(), QVBoxLayout() # --- 图像区域 --- self.img_label = QLabel("加载图片后左键拖拽 (框内平移,框外重绘)") self.img_label.setAlignment(Qt.AlignCenter) self.img_label.setFixedSize(900, 700) self.img_label.setStyleSheet("background-color: #1e1e1e; border: 1px solid #333;") self.img_label.mousePressEvent = self.mouse_press self.img_label.mouseMoveEvent = self.mouse_move self.img_label.mouseReleaseEvent = self.mouse_release left_panel.addWidget(self.img_label) # --- 右侧面板 --- # 1. 基础操作 op_group = QGroupBox("基础操作") op_v = QVBoxLayout() btn_open = QPushButton("📂 加载本地图片"); btn_open.clicked.connect(self.open_image) op_v.addWidget(btn_open); op_group.setLayout(op_v); right_panel.addWidget(op_group) # 2. 算法参数 algo_group = QGroupBox("算法与姿态控制") algo_v = QVBoxLayout() def add_sld(name, min_v, max_v, def_v, is_sigma=False): lay = QHBoxLayout(); lay.addWidget(QLabel(name)) sld = QSlider(Qt.Horizontal); sld.setRange(min_v, max_v); sld.setValue(def_v) v_l = QLabel(str(def_v / 10.0 if is_sigma else def_v)); v_l.setFixedWidth(35) sld.valueChanged.connect(lambda v: v_l.setText(str(v / 10.0 if is_sigma else v))) sld.valueChanged.connect(self.run_caliper) lay.addWidget(sld); lay.addWidget(v_l); algo_v.addLayout(lay); return sld self.sld_width = add_sld("搜索宽度:", 5, 200, 40) self.sld_num = add_sld("卡尺数量:", 2, 100, 15) self.sld_thres = add_sld("梯度阈值:", 1, 255, 30) self.sld_sigma = add_sld("平滑(Sigma):", 0, 100, 10, True) lay_trans = QHBoxLayout(); lay_trans.addWidget(QLabel("边缘极性:")) self.cmb_trans = QComboBox(); self.cmb_trans.addItems(["all", "positive", "negative"]) self.cmb_trans.currentIndexChanged.connect(self.run_caliper); lay_trans.addWidget(self.cmb_trans) algo_v.addLayout(lay_trans) rot_lay = QHBoxLayout() btn_l = QPushButton("↺ 逆时针旋转"); btn_r = QPushButton("顺时针旋转 ↻") btn_l.clicked.connect(lambda: self.rotate_caliper(-self.ROTATION_STEP)) btn_r.clicked.connect(lambda: self.rotate_caliper(self.ROTATION_STEP)) rot_lay.addWidget(btn_l); rot_lay.addWidget(btn_r); algo_v.addLayout(rot_lay) algo_group.setLayout(algo_v); right_panel.addWidget(algo_group) # 3. 样式定制 style_group = QGroupBox("视觉效果定制") style_v = QVBoxLayout() l_lay = QHBoxLayout(); btn_lc = QPushButton("线颜色") btn_lc.clicked.connect(self.pick_line_col) self.spn_lw = QSpinBox(); self.spn_lw.setRange(1, 10); self.spn_lw.setValue(1); self.spn_lw.valueChanged.connect(self.update_display) l_lay.addWidget(btn_lc); l_lay.addWidget(QLabel("线宽:")); l_lay.addWidget(self.spn_lw); style_v.addLayout(l_lay) p_lay = QHBoxLayout(); btn_pc = QPushButton("点颜色") btn_pc.clicked.connect(self.pick_pt_col) self.spn_pr = QSpinBox(); self.spn_pr.setRange(1, 15); self.spn_pr.setValue(2); self.spn_pr.valueChanged.connect(self.update_display) p_lay.addWidget(btn_pc); p_lay.addWidget(QLabel("点径:")); p_lay.addWidget(self.spn_pr); style_v.addLayout(p_lay) style_group.setLayout(style_v); right_panel.addWidget(style_group) # 4. 测量结果 res_group = QGroupBox("测量结果") self.ang_lab = QLabel("直线角度: -- °") self.ang_lab.setStyleSheet("font-size: 20px; color: #00FF00; font-weight: bold;") rv = QVBoxLayout(); rv.addWidget(self.ang_lab); res_group.setLayout(rv); right_panel.addWidget(res_group) right_panel.addStretch(); container = QWidget(); main_layout.addLayout(left_panel); main_layout.addLayout(right_panel) container.setLayout(main_layout); self.setCentralWidget(container) def pick_line_col(self): c = QColorDialog.getColor(); if c.isValid(): self.line_color = (c.blue(), c.green(), c.red()); self.update_display() def pick_pt_col(self): c = QColorDialog.getColor(); if c.isValid(): self.pt_color = (c.blue(), c.green(), c.red()); self.update_display() def open_image(self): path, _ = QFileDialog.getOpenFileName(self, "打开图片", "", "Images (*.png *.jpg *.bmp)") if path: self.raw_image = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR) if self.raw_image is not None: self.p1 = self.p2 = self.fitted_line = None; self.detected_pts = [] h, w = self.raw_image.shape[:2]; self.scale_ratio = min(900 / w, 700 / h) self.offset_x, self.offset_y = (900 - int(w * self.scale_ratio)) // 2, ( 700 - int(h * self.scale_ratio)) // 2 self.update_display() def get_raw_coords(self, ex, ey): return (ex - self.offset_x) / self.scale_ratio, (ey - self.offset_y) / self.scale_ratio def mouse_press(self, event): if self.raw_image is None or event.button() != Qt.LeftButton: return rx, ry = self.get_raw_coords(event.x(), event.y()) box = LineCaliperCore.get_search_box(self.p1, self.p2, self.sld_width.value()) if box and cv2.pointPolygonTest(np.array(box, np.float32), (rx, ry), False) >= 0: self.mode, self.drag_start_pos = "dragging", (rx, ry) self.drag_start_p1, self.drag_start_p2 = self.p1, self.p2 else: self.mode = "drawing"; self.p1 = self.p2 = (rx, ry); self.update_display() def mouse_move(self, event): if self.raw_image is None: return rx, ry = self.get_raw_coords(event.x(), event.y()) if self.mode == "drawing": self.p2 = (rx, ry); self.run_caliper() elif self.mode == "dragging": dx, dy = rx - self.drag_start_pos[0], ry - self.drag_start_pos[1] self.p1 = (self.drag_start_p1[0] + dx, self.drag_start_p1[1] + dy) self.p2 = (self.drag_start_p2[0] + dx, self.drag_start_p2[1] + dy); self.run_caliper() def mouse_release(self, event): self.mode = "none" def run_caliper(self): if self.raw_image is not None and self.p1 and self.p2: success, lp1, lp2, pts, ang = LineCaliperCore.find_line( self.raw_image, self.p1, self.p2, self.sld_width.value(), self.sld_num.value(), self.sld_thres.value(), sigma=self.sld_sigma.value() / 10.0, transition=self.cmb_trans.currentText() ) self.detected_pts, self.fitted_line = pts, (lp1, lp2) if success else None self.ang_lab.setText(f"直线角度: {ang:.2f} °"); self.update_display() def rotate_caliper(self, deg): if not self.p1 or not self.p2: return p1, p2 = np.array(self.p1), np.array(self.p2); ctr = (p1 + p2) / 2 rad = math.radians(deg); c, s = math.cos(rad), math.sin(rad); m = np.array([[c, -s], [s, c]]) self.p1, self.p2 = tuple(np.dot(p1 - ctr, m) + ctr), tuple(np.dot(p2 - ctr, m) + ctr) self.run_caliper() def update_display(self): if self.raw_image is None: return draw = self.raw_image.copy() if self.fitted_line: cv2.line(draw, self.fitted_line[0], self.fitted_line[1], self.line_color, self.spn_lw.value()) for pt in self.detected_pts: cv2.circle(draw, (int(pt[0]), int(pt[1])), self.spn_pr.value(), self.pt_color, -1) if self.p1 and self.p2: box = LineCaliperCore.get_search_box(self.p1, self.p2, self.sld_width.value()) if len(box) == 4: cv2.polylines(draw, [np.array(box, np.int32)], True, (255, 120, 0), 1) cv2.arrowedLine(draw, tuple(map(int, self.p1)), tuple(map(int, self.p2)), (0, 255, 255), 1, tipLength=0.05) h, w = draw.shape[:2]; nw, nh = int(w * self.scale_ratio), int(h * self.scale_ratio) disp = cv2.resize(draw, (nw, nh)); canvas = np.zeros((700, 900, 3), dtype=np.uint8) canvas[self.offset_y:self.offset_y + nh, self.offset_x:self.offset_x + nw] = disp q = QImage(canvas.data, 900, 700, 900 * 3, QImage.Format_BGR888) self.img_label.setPixmap(QPixmap.fromImage(q)) if __name__ == "__main__": app = QApplication(sys.argv); ex = CaliperApp(); ex.show(); sys.exit(app.exec_())
效果测试图:

3. 实现逻辑
-
LineCaliperCore (算法层):
-
find_line(): 核心逻辑。负责坐标转换、通道分离、平滑处理、梯度计算及 Huber 拟合。
-
get_search_box(): 几何计算逻辑。利用法向量生成矩形搜索框,辅助可视化。
-
-
(应用层):
-
基于 PyQt5 开发。实现了图像的自适应缩放(Scale Ratio)和坐标映射。
-
交互模式:支持“绘图模式(Drawing)”和“平移模式(Dragging)”。
-
动态调参:支持搜索宽度、卡尺数量、平滑系数、极性(Positive/Negative/All)的实时反馈。
-
4. 应用场景
-
工业测量:检测零件边缘的角度和位置。
-
视觉对位:在贴片机或点胶机中寻找工件基准边。
-
质量检测:判断产品边缘是否存在缺口或毛刺
5. 代码中调用
实际使用过程中,有时候只想要调用核心算法,不需要界面,那么代码如下:
import cv2 import numpy as np from caliper_core import LineCaliperCore def main(): # 1. 读取图像 image = cv2.imread('test_image.png') if image is None: print("错误:无法读取图片") return # 2. 定义初始的“期望直线”位置 (p1, p2) # 这通常是你手动指定的搜索中心线,卡尺会垂直于这条线进行搜索 p1 = (260, 150) p2 = (250, 300) # 3. 设置参数并调用算法 # 参数说明: # search_width: 往中心线两侧各搜多少像素 # num_calipers: 均匀分布多少个卡尺 # threshold: 边缘梯度阈值 # transition: 'all' (所有边缘), 'positive' (暗到亮), 'negative' (亮到暗) success, res_p1, res_p2, points, angle = LineCaliperCore.find_line( image, p1, p2, search_width=20, num_calipers=10, threshold=15, sigma=1.5, transition='all' ) # 4. 处理结果 if success: print(f"直线拟合成功!") print(f"拟合起点: {res_p1}") print(f"拟合终点: {res_p2}") print(f"直线角度: {angle:.2f} 度") print(f"提取到的有效特征点数: {len(points)}") # --- 可选:将结果绘制并保存,用于验证 --- # 绘制搜索框区域 box = LineCaliperCore.get_search_box(p1, p2, 20) cv2.polylines(image, [np.array(box, np.int32)], True, (255, 0, 0), 1) # 绘制拟合出的绿色直线 cv2.line(image, res_p1, res_p2, (0, 255, 0), 2) # 绘制检测到的红点 for pt in points: cv2.circle(image, (int(pt[0]), int(pt[1])), 3, (0, 0, 255), -1) cv2.imwrite('result.jpg', image) print("结果已保存至 result.jpg") else: print("未能拟合出直线,请检查参数或初始位置。") if __name__ == "__main__": main() ''' 直线拟合成功! 拟合起点: (267, 321) 拟合终点: (270, 130) 直线角度: -89.32 度 提取到的有效特征点数: 10 结果已保存至 result.jpg '''

打印信息中包括了拟合的起始点与终点,也包括了拟合的直线的角度。如果在后续程序中需要用到这条直线,你可以放心使用拟合起点(res_p1) 和拟合终点(res_p2)。
小结:拟合直线是在做视觉任务中常常用到,直线卡尺很好的实现了这一点,本文模拟了halcon中的直线卡尺核心功能。另外本文拟合直线使用了Huber算法,如果背景干扰性比较强,可以尝试使用极端一点的RANSAC算法。关于这两者比较放在下方了。

不足或错误之处,欢迎指出与评论!
参考资料:
https://blog.csdn.net/fang_1_9_8_7/article/details/149575867
https://blog.csdn.net/kUhzIPVBnE/article/details/159158917
https://www.cnblogs.com/zdfffg/p/11572249.html


浙公网安备 33010602011771号