简介:「曲线图片识别程序」是一份面向科研人员与MATLAB学习者的图像数据处理工具包,用于从图片中自动提取曲线坐标,省去对照论文图形重写代码的麻烦。整套流程覆盖灰度化与二值化预处理、Canny等边缘检测、连通区域标记与轮廓追踪、polyfit曲线拟合,以及plot绘图对比与imread/imwrite文件读写,末尾还留有GUI交互与算法提速的扩展空间。压缩包共14个文件、约459KB,主体为12张png示例图片,配合1个mat数据文件和1个m脚本,可直接跑通识别流程并对照样例验证效果。已有2019人学习下载,适合需要复现文献曲线、做数据对比的中高级使用者参考。
1. 一张曲线截图能不能直接变数据:曲线图片识别程序的落地边界
把一张论文截图里的曲线还原成可计算的数值,是曲线图片识别程序最典型的用途。很多人第一反应是找现成软件,其实在 matlab 里用图像处理工具箱搭一条最小流程,半小时就能跑出第一版数据。这个资源适合手里只有图片、没有原始数据的学生和工程师,也适合需要批量复现实验曲线的人。它解决的不是“读图”本身,而是把像素位置映射回坐标轴数值,再导出成可拟合、可对比的 CSV。别把它当成万能 OCR,网格、图例和抗锯齿都会让结果变玄学,先把边界认清再动手。适合谁:能看懂 MATLAB 脚本、愿意花十分钟标定坐标轴、对误差有基本判断的人。不适合谁:指望一键导入就得到完美数据、从不检查叠图的人。
2. 从像素到数值:MATLAB 曲线识别的主流程与四个函数选型
曲线图片识别程序的核心不是某一个神奇函数,而是一条从读图到导出的顺序链。我的习惯是先把“图像处理”和“坐标映射”拆开:前半段只关心怎么把曲线从背景里剥出来,后半段只关心像素点怎么变成数据点。拆开以后,哪一步翻车都能单独回看,不用在一大段脚本里猜黑匣子。常见做法是读图、转灰度、二值化、去噪、骨架化、取点、标定、映射、导出,这九步里每一步都有可调参数,但真正影响结果的只有通道选择、阈值、形态学核和坐标锚点。
2.1 读图、转灰度与通道选择:先确认曲线和网格是不是同一层信息
直接rgb2gray最省事,但它会把颜色对比度压扁。如果曲线是红色、网格是灰色,灰度化后两者可能黏在一起,后面阈值再调也分不开。我一般先看原图的通道分布,再决定是走灰度还是走 HSV 分割。读图时用imfinfo看一眼位深和透明通道,避免 PNG 的 alpha 通道在叠加后产生灰边。
% 读取曲线截图,保留原始信息;统一转 double 便于后续运算 img = imread('curve_demo.png'); info = imfinfo('curve_demo.png'); fprintf('位深: %d, 颜色类型: %s\n', info.BitDepth, info.ColorType); gray = rgb2gray(img); grayD = im2double(gray); % 预览灰度图,先肉眼判断曲线与网格是否分离 figure; imshow(gray); title('灰度预览:先看曲线和网格是否粘连');这段代码的作用是建立统一入口。imread支持 png、jpg、bmp 等常见格式;im2double把像素归一到 0 到 1,避免后面做减法和滤波时 uint8 溢出。imfinfo不是必须,但遇到透明背景图时很有用,ColorType为truecolor且带 alpha 时,最好先合成白底再处理。参数上唯一需要留意的是图片路径,批量处理时不要用中文路径和空格混排,MATLAB 对路径中的特殊字符并不总是友好。
如果曲线是彩色而网格是灰色,我会先做颜色分离,而不是直接灰度。下面这段用 HSV 空间把高饱和度像素挑出来,适合红、蓝、绿曲线。阈值不是固定的,S通道阈值 0.35 只是起点,实际要看直方图。
% 彩色曲线优先走 HSV,把高饱和度区域作为候选曲线 hsv = rgb2hsv(img); S = hsv(:, :, 2); V = hsv(:, :, 3); % S 阈值控制“多彩算曲线”,V 阈值排除过暗背景 curveMask = (S > 0.35) & (V > 0.25); % 去掉零碎噪点,保留主要曲线 curveMask = bwareaopen(curveMask, 50); figure; imshow(curveMask); title('HSV 饱和度分割候选曲线');逻辑说明:S是饱和度,曲线颜色越纯,S越高;V是明度,用来排除黑色文字和深色边框。bwareaopen的面积阈值 50 需要按图片分辨率调整,分辨率翻倍时面积阈值大约按平方放大。这个方案对彩色曲线有效,对黑白曲线不适用,黑白图还是回到灰度加 Otsu 的路线。选型理由很简单:颜色信息是额外的维度,能用就用,不能用再退回灰度,不要一上来就把彩色图压成灰度。
2.2 阈值、形态学与连通域:把“线”从背景里剥出来
二值化是曲线图片识别程序里最容易“看起来能跑、实际全是毛刺”的一步。graythresh给的是全局 Otsu 阈值,适合背景均匀、光照一致的截图;如果图里有渐变背景或阴影,全局阈值会把一半网格留下来。我的做法是先跑 Otsu 看效果,再决定要不要局部自适应。二值化之后不要急着骨架化,先做一次面积开运算和闭运算,把孤点去掉、把小断点接上。
% 灰度路线:Otsu 全局阈值,再按曲线深浅决定是否取反 level = graythresh(grayD); bw = imbinarize(grayD, level); % 曲线通常是深色,背景浅色,这里取反让曲线为 1 bw = ~bw; % 去掉小面积噪点;面积阈值按最小曲线片段估算 bw = bwareaopen(bw, 30); % 闭运算连接 1 到 2 像素的断点,开运算去掉毛刺 seClose = strel('disk', 2); seOpen = strel('square', 2); bw = imclose(bw, seClose); bw = imopen(bw, seOpen); figure; imshow(bw); title('二值化与形态学清理后的曲线候选');参数说明:graythresh返回 0 到 1 的归一化阈值,imbinarize直接使用;bwareaopen的 30 是经验值,如果曲线很细、分辨率很高,30 可能把曲线本身切断,这时要降到 10 左右。strel('disk', 2)的半径 2 适合 1 到 3 像素宽的曲线,半径太大曲线会糊成块,骨架化后位置偏移明显。imclose先闭后开不是固定顺序,断点多就先闭,毛刺多就先开,两个都严重时分别调小核半径,不要指望一个大核解决所有问题。
连通域筛选是第二道保险。二值图里留下来的不只有曲线,还有坐标轴、刻度、图例文字。regionprops可以按长宽比、面积、欧拉数筛掉明显不是曲线的东西。曲线通常细长,长宽比大;文字块面积小、长宽比接近 1;坐标轴是水平或垂直的长条,和曲线方向不同。
% 连通域筛选:保留细长且面积不过小的候选 cc = bwconncomp(bw); stats = regionprops(cc, 'Area', 'BoundingBox', 'MajorAxisLength', 'MinorAxisLength'); keep = false(cc.NumObjects, 1); for k = 1:cc.NumObjects area = stats(k).Area; major = stats(k).MajorAxisLength; minor = stats(k).MinorAxisLength; ratio = major / max(minor, eps); % 面积下限排除文字;长宽比下限排除方块状图例 if area > 80 && ratio > 3 keep(k) = true; end end bwCurve = false(size(bw)); bwCurve(vertcat(cc.PixelIdxList{keep})) = true; figure; imshow(bwCurve); title('连通域筛选后的曲线区域');逻辑说明:bwconncomp把二值图拆成独立连通块,regionprops给出每个块的面积、外接矩形和长短轴。ratio > 3是“细长”的粗略门槛,曲线弯折厉害时MajorAxisLength不一定大,所以这个条件只适合做粗筛,不要当成硬规则。面积下限 80 同样依赖分辨率,批量处理时最好用图片总像素的比例来设,比如area > 0.0001 * numel(bw)。这一步的目标不是完美,而是把明显不是曲线的块去掉,剩下的交给骨架和排序。
2.3 坐标轴四点标定:像素坐标到数据坐标的仿射变换
曲线识别最关键的参数不是阈值,而是坐标锚点。没有标定,骨架点只是像素坐标,导出 CSV 也没有物理意义。最小标定需要四个点:x 轴左端、x 轴右端、y 轴下端、y 轴上端,通常 x 轴左端和 y 轴下端是同一个轴交点。手动取点用ginput最稳,自动检测刻度线适合批量但容易把网格线误认成刻度。我的习惯是:第一张图一定手动取点,把比例因子记下来,后面同版式的图再用自动或复用。
% 手动取四个锚点:按 x 轴左端、x 轴右端、y 轴下端、y 轴上端的顺序点 figure; imshow(img); title('依次点击:x 轴左端、x 轴右端、y 轴下端、y 轴上端'); [xpix, ypix] = ginput(4); % 对应的数据坐标,按图上的刻度填写 xData = [0, 10]; % x 轴起止值 yData = [0, 5]; % y 轴起止值 % 像素差与数据差的比例,单位是 pixel/unit kx = (xpix(2) - xpix(1)) / (xData(2) - xData(1)); ky = (ypix(3) - ypix(4)) / (yData(2) - yData(1)); fprintf('kx = %.4f pixel/unit, ky = %.4f pixel/unit\n', kx, ky);逻辑说明:ginput(4)会暂停脚本等待鼠标点击,点的顺序必须固定,否则后面索引会乱。kx用 x 轴两端像素差除以数据差,ky用 y 轴下端减上端,因为图像 y 轴向下增长,而数据 y 轴向上增长。参数上要注意:如果 x 轴不是从 0 开始,xData要填实际起止值;如果坐标轴是对数轴,这个线性映射不成立,需要先做对数变换。手动取点的误差一般在一到两个像素,换算到数据坐标后是否可接受,要看你的曲线精度要求。
2.4 主流程骨架:一个可跑的曲线识别脚本
把前面的步骤串起来,就是一个最小可跑的曲线图片识别程序。这个版本不追求自动排序,只负责把骨架点映射成数据点并导出,适合先验证“这套图能不能识别”。真正要用的时候,排序和断点修复放在下一章处理。
% 最小主流程:读图 -> 灰度 -> 二值化 -> 去噪 -> 骨架 -> 映射 -> 导出 img = imread('curve_demo.png'); gray = rgb2gray(img); grayD = im2double(gray); level = graythresh(grayD); bw = ~imbinarize(grayD, level); bw = bwareaopen(bw, 30); bw = imclose(bw, strel('disk', 2)); bw = imopen(bw, strel('square', 2)); % 骨架化,Inf 表示反复细化到不再变化 skel = bwmorph(bw, 'skel', Inf); % find 返回 [行, 列],转成 [x, y] 像素坐标 [y, x] = find(skel); pts = [x, y]; % 使用上一节手动标定得到的锚点 x1 = xpix(1); x2 = xpix(2); y1 = ypix(4); y2 = ypix(3); x0 = xData(1); xN = xData(2); y0 = yData(1); yN = yData(2); data = zeros(size(pts)); data(:, 1) = x0 + (pts(:, 1) - x1) * (xN - x0) / (x2 - x1); data(:, 2) = y0 + (y2 - pts(:, 2)) * (yN - y0) / (y2 - y1); writematrix(data, 'curve_points_raw.csv'); fprintf('导出 %d 个骨架点\n', size(data, 1));逻辑说明:bwmorph(bw, 'skel', Inf)把粗线细化成单像素骨架,Inf表示一直迭代到稳定。find的顺序是按列扫描,得到的点没有沿曲线排序,直接画出来会像散点,所以这个版本只适合检查覆盖范围,不适合直接拟合。映射公式里 x 方向用(pts(:,1)-x1)/(x2-x1),y 方向用(y2-pts(:,2))/(y2-y1),是因为图像 y 向下、数据 y 向上。参数上,x1、x2来自手动点击,x0、xN来自图上的刻度值,这两组数必须对应同一根轴,不要拿 x 轴锚点去算 y 轴。
3. 坐标轴标定与像素映射:两条轴、四个锚点和比例因子的确定方法
坐标标定是曲线图片识别程序里最值得花时间的一步。阈值调坏了还能重跑,锚点取错了,后面所有数据整体平移或缩放,肉眼还不一定看得出来。我见过最常见的翻车是:x 轴锚点取在绘图区边缘而不是刻度线起点,导致整条曲线往左偏半个刻度;y 轴锚点取在轴线上端而不是最高刻度,导致幅值整体压缩。这一章把标定拆成手动、自动、多曲线分组和残差校验四件事,每件事都给可执行的写法。
3.1 手动取点与自动检测刻度线:什么时候该信人工
第一张图一定手动取点,这不是保守,是成本最低的验证。自动检测刻度线听起来高级,但它依赖刻度线比曲线更规则、更细、更短,一旦图里有网格线、次刻度、图例边框,检测结果很容易跑偏。手动取点的代价是每次点击四下,换来的是比例因子可控。批量处理时,如果所有图版式一致,可以把第一张图的锚点像素保存下来复用;如果版式有缩放,就要按图片尺寸比例换算,不能直接套。
% 保存手动标定结果,供同版式图片复用 calib.xpix = xpix; calib.ypix = ypix; calib.xData = xData; calib.yData = yData; save('calib_demo.mat', 'calib'); % 复用前先检查图片尺寸是否一致 newImg = imread('curve_demo_02.png'); if ~isequal(size(newImg, 1:2), size(img, 1:2)) warning('图片尺寸不一致,锚点需要按比例换算或重新取点'); end逻辑说明:calib结构体保存四个锚点的像素坐标和对应数据坐标,save成 mat 文件方便下一张图加载。复用前比较size(newImg, 1:2)和原图尺寸,尺寸不一致时不能直接套,因为像素比例变了。常见做法是按宽高比例缩放锚点:xpix_new = xpix * width_new / width_old,但前提是绘图区在图片中的相对位置没变。如果截图被裁剪过,比例换算也会失效,这种情况老老实实重新点四下。
自动检测刻度线可以作为辅助,不适合作为唯一依据。思路是先检测水平线和垂直线,再按长度和位置筛选。下面这段用形态学开运算提取长直线,只适合演示筛选逻辑,实际参数要按图调。
% 自动提取水平长线,辅助定位 x 轴和网格线 bwInv = ~imbinarize(grayD, graythresh(grayD)); horiz = imopen(bwInv, strel('line', 40, 0)); vert = imopen(bwInv, strel('line', 40, 90)); % 按连通域长度筛选,保留接近整幅图宽度的水平线 ccH = bwconncomp(horiz); statsH = regionprops(ccH, 'BoundingBox'); for k = 1:ccH.NumObjects w = statsH(k).BoundingBox(3); if w > 0.6 * size(bwInv, 2) fprintf('候选水平轴线,宽度 %.1f 像素\n', w); end end逻辑说明:strel('line', 40, 0)是长度 40、角度 0 度的线结构元素,用来保留水平长线;角度 90 度保留垂直线。0.6 * size(bwInv, 2)是“接近整幅图宽度”的阈值,适合坐标轴横跨大半张图的情况。这个检测只能给出候选,不能直接当锚点,因为网格线也可能满足条件。我一般用它来确认“x 轴大概在哪一行”,再回到手动取点确认左右端点。
3.2 仿射变换的矩阵写法与轴向反转
两点比例映射适合正放的坐标轴,遇到轻微旋转或非正交轴,仿射变换更稳。仿射矩阵可以把像素坐标[x, y, 1]映射到数据坐标[X, Y, 1],用三个锚点就能解。常见做法是用fitgeotrans或手工解线性方程,前者更省事,后者更透明。我的习惯是手工写一遍,知道矩阵里每个数在干什么,再用affine2d做批量变换。
% 用三个锚点解仿射矩阵:像素坐标 -> 数据坐标 % 锚点顺序:x 轴左端、x 轴右端、y 轴上端 P = [xpix(1), ypix(1), 1; xpix(2), ypix(2), 1; xpix(4), ypix(4), 1]; Q = [xData(1), yData(1); xData(2), yData(1); xData(1), yData(2)]; % 解 3x3 仿射矩阵,最后一行为 [0 0 1] A = P \ [Q, ones(3, 1)]; tform = affine2d(A); % 批量变换骨架点 [xr, yr] = transformPointsForward(tform, pts(:, 1), pts(:, 2)); dataAffine = [xr, yr];逻辑说明:P是像素坐标齐次矩阵,Q是对应数据坐标,P \ [Q, ones(3,1)]解出 3x3 矩阵A。affine2d把它包装成变换对象,transformPointsForward对任意数量的点做正向映射。参数上要注意锚点不能共线,三个点要覆盖 x 和 y 两个方向;如果 y 轴方向反了,Q里的yData顺序要对应上端和下端,不要照抄。仿射变换能处理轻微旋转,但如果坐标轴弯曲或透视明显,线性仿射不够用,需要透视变换或多段标定。
轴向反转是另一个高频问题。图像坐标 y 向下,数据坐标 y 向上,直接用y_data = y_pix * ky会得到倒过来的曲线。正确写法是y_data = y0 + (y_bottom - y_pix) * (yN - y0) / (y_bottom - y_top),其中y_bottom是 y 轴下端像素,y_top是上端像素。这个式子看起来长,但把它写进函数后就不容易错。
% 封装像素转数据函数,固定轴向反转逻辑 toData = @(p) [xData(1) + (p(:, 1) - xpix(1)) * (xData(2) - xData(1)) / (xpix(2) - xpix(1)), ... yData(1) + (ypix(3) - p(:, 2)) * (yData(2) - yData(1)) / (ypix(3) - ypix(4))]; dataXY = toData(pts);逻辑说明:匿名函数toData接收 N×2 的像素点矩阵,返回 N×2 的数据点矩阵。x 方向用左端锚点和右端锚点做线性插值,y 方向用下端减当前像素,再除以下端减上端。参数xpix(1)、xpix(2)、ypix(3)、ypix(4)必须来自同一张图的手动标定,不能混用不同图片的锚点。这个函数适合正放坐标轴,遇到旋转图还是用仿射矩阵。
3.3 多曲线图的分组:颜色分割和连通域筛选
一张图里有多条曲线时,先分组再取点,不要把所有骨架点混在一起导出。分组依据优先看颜色,其次看线型,最后看空间位置。颜色分组用 HSV 最直接,给每条曲线一个饱和度或色相范围;黑白图只能靠连通域和位置,交叉点会把两条曲线粘成一个连通块,这时需要手动框选区域或按列扫描分段。
% 按色相范围分三条彩色曲线,适合红、绿、蓝区分明显的图 hsv = rgb2hsv(img); H = hsv(:, :, 1); S = hsv(:, :, 2); V = hsv(:, :, 3); maskRed = (H < 0.05 | H > 0.95) & S > 0.4 & V > 0.2; maskGreen = (H > 0.25 & H < 0.45) & S > 0.4 & V > 0.2; maskBlue = (H > 0.55 & H < 0.75) & S > 0.4 & V > 0.2; % 每条曲线单独去噪、骨架化 curves = {maskRed, maskGreen, maskBlue}; names = {'red', 'green', 'blue'}; for k = 1:numel(curves) m = bwareaopen(curves{k}, 40); sk = bwmorph(m, 'skel', Inf); [yy, xx] = find(sk); fprintf('%s: %d 个骨架点\n', names{k}, numel(xx)); end逻辑说明:色相H在 0 到 1 之间循环,红色分布在 0 附近和 1 附近,所以要写H < 0.05 | H > 0.95;绿色大约在 0.33,蓝色大约在 0.66。S > 0.4排除灰色网格,V > 0.2排除黑色文字。每组单独bwareaopen和bwmorph,避免不同颜色的曲线互相干扰。参数不是固定的,截图色偏严重时先取一个像素看 HSV 值,再定范围。黑白多曲线图不适合这段,得用连通域加手动区域。
3.4 用已知网格做残差校验
标定完不要直接导出,先拿图上的已知网格做一次残差校验。找几个网格交点,用同一套映射算它们的像素坐标对应数据坐标,再和刻度值比较,误差超过半个小刻度就说明锚点或映射有问题。这个步骤花两分钟,能省掉后面几个小时的对不上。
% 选三个已知网格交点,手动记录像素坐标和真实数据坐标 checkPix = [300, 500; 500, 500; 500, 300]; checkTrue = [3.0, 2.0; 5.0, 2.0; 5.0, 4.0]; checkPred = toData(checkPix); residual = checkPred - checkTrue; rmse = sqrt(mean(residual(:).^2)); fprintf('网格校验 RMSE = %.4f\n', rmse); % 误差超过半个小刻度就回查锚点 if rmse > 0.05 warning('标定残差偏大,检查锚点是否取在刻度线起点'); end逻辑说明:checkPix是网格交点的像素坐标,checkTrue是对应刻度值,toData复用前面的映射函数。residual是预测值减真实值,rmse是整体误差。阈值 0.05 只是示例,实际按你的数据精度要求设;如果最小刻度是 0.1,残差超过 0.05 就值得回查。常见误差来源是锚点取在轴线边缘而不是刻度线中心,或者 x 轴起点不是 0 但xData填了 0。
4. 曲线骨架提取与断点修复:把粗线变成有序点列
骨架化只是把粗线变细,得到的点还是无序的。直接导出find的结果,CSV 里是一堆散点,拟合时曲线会自己连来连去。这一章解决三件事:怎么得到单像素骨架、怎么把点按 x 或按路径排好、断点和交叉点怎么修。曲线图片识别程序能不能用于拟合,差别主要在这里,而不是阈值那一步。
4.1 二值图细化与骨架化:bwmorph 的用法和参数边界
bwmorph的'skel'和'thin'都能细化,区别是'skel'保留拓扑结构,'thin'更接近中轴。曲线识别一般用'skel',因为后面要按连通路径走。迭代次数写Inf表示细化到稳定,写具体数字会提前停止,适合已知线宽的情况。参数边界在于:线宽超过 5 像素时,骨架化后可能出现小分支;线宽只有 1 像素时,骨架化基本不变,但二值化噪声会直接变成假分支。
% 对比 skel 和 thin 的骨架结果 skel = bwmorph(bwCurve, 'skel', Inf); thin = bwmorph(bwCurve, 'thin', Inf); % 统计骨架像素数,差异过大时回看二值图 fprintf('skel 像素数: %d, thin 像素数: %d\n', nnz(skel), nnz(thin)); figure; subplot(1, 2, 1); imshow(skel); title('skel 骨架'); subplot(1, 2, 2); imshow(thin); title('thin 细化');逻辑说明:bwCurve是连通域筛选后的二值图,nnz统计非零像素。skel和thin的像素数差太多时,通常说明二值图里有粗块或毛刺,先回去调形态学核,不要指望骨架化修复。Inf迭代在大多数截图上是安全的,但图片特别大时会慢,可以先用'skel', 5试跑,确认效果后再改Inf。骨架化不会修复断点,断点还在二值图里,后面单独处理。
4.2 按列扫描与连通路径:处理多值函数和回环
曲线如果是单值函数,按 x 列扫描最稳:每一列取骨架点的 y 中位数,得到一条有序点列。多值函数,比如闭合曲线或回环,按列扫描会把上下两支压成一个值,这时要按连通路径走,从一端出发沿着 8 邻域找下一个点。我的习惯是:先判断曲线是不是单值,单值用列扫描,多值用路径追踪,不要一套代码硬套所有图。
% 单值曲线:按列扫描,每列取骨架 y 的中位数 [yy, xx] = find(skel); u = unique(xx); xSorted = zeros(numel(u), 1); ySorted = zeros(numel(u), 1); for k = 1:numel(u) idx = xx == u(k); xSorted(k) = u(k); ySorted(k) = median(yy(idx)); end % 去掉空列造成的 NaN,再映射到数据坐标 valid = ~isnan(ySorted); ptsSorted = [xSorted(valid), ySorted(valid)]; dataSorted = toData(ptsSorted);逻辑说明:unique(xx)得到所有有骨架点的列,median(yy(idx))取该列骨架点的中位数,能压掉少量毛刺。valid去掉没有骨架点的列,避免后面插值时出现 NaN。列扫描适合从左到右单调的曲线,如果曲线有垂直段,同一列会有多个 y,中位数会把垂直段压成一个点,导致形状失真。遇到这种情况,改用路径追踪,或者把图旋转到曲线尽量水平再扫描。
路径追踪的核心是 8 邻域查找。下面这段从最左上的骨架点出发,每次找未访问的邻居,适合开放曲线。闭合曲线要额外处理首尾相连,否则会在起点附近停住。
% 8 邻域路径追踪,适合开放曲线 [y0, x0] = find(skel, 1, 'first'); visited = false(size(skel)); path = [x0, y0]; visited(y0, x0) = true; neighbors = [-1 -1; -1 0; -1 1; 0 -1; 0 1; 1 -1; 1 0; 1 1]; while true found = false; for k = 1:size(neighbors, 1) ny = path(end, 2) + neighbors(k, 1); nx = path(end, 1) + neighbors(k, 2); if ny >= 1 && ny <= size(skel, 1) && nx >= 1 && nx <= size(skel, 2) ... && skel(ny, nx) && ~visited(ny, nx) path(end+1, :) = [nx, ny]; visited(ny, nx) = true; found = true; break; end end if ~found break; end end逻辑说明:neighbors是 8 邻域偏移,visited防止回环。每次从当前点找第一个未访问的骨架邻居,找不到就结束。这段代码在分叉点会选第一个方向,可能走错支路,所以只适合没有分叉的开放曲线。find(skel, 1, 'first')取的是按列扫描的第一个点,不一定是曲线端点,起点选错会导致路径从中间开始。更稳的做法是先找端点,也就是只有一个邻居的骨架点。
4.3 断点、毛刺和交叉点的修复策略
断点来自二值化不连续,毛刺来自骨架化分叉,交叉点来自两条曲线重叠。三种问题的修法不同:断点用闭运算或形态学桥接,毛刺用剪枝,交叉点只能靠分组或手动分段。不要用一个imclose大核同时解决,核太大会把交叉点糊成块,后面更麻烦。
% 断点桥接:先找端点,再在短距离内连接两个端点 skelDub = bwmorph(skel, 'spur', 5); % 先剪掉 5 像素以内的毛刺 endpoints = bwmorph(skelDub, 'endpoints'); [ey, ex] = find(endpoints); % 端点两两配对,距离小于 6 像素时画线桥接 bridge = false(size(skelDub)); for i = 1:numel(ex) for j = i+1:numel(ex) d = hypot(ex(i)-ex(j), ey(i)-ey(j)); if d > 0 && d < 6 bridge = bridge | drawLineMask(size(skelDub), [ex(i), ey(i)], [ex(j), ey(j)]); end end end skelFixed = skelDub | bridge;逻辑说明:bwmorph(skel, 'spur', 5)剪掉长度不超过 5 像素的毛刺,参数 5 按毛刺长度调,太大会把真实短分支剪掉。endpoints找只有一个邻居的端点,hypot算端点距离。距离阈值 6 像素适合小断点,断口更大时要先回看二值图,可能是阈值把曲线切断了,光靠桥接会连错。drawLineMask是自定义函数,用polyline或 Bresenham 生成线掩膜,这里不展开,思路是把两个端点之间的像素补上。交叉点不在这段处理,交叉处端点不明显,桥接容易把两条曲线连成一条。
4.4 导出 CSV 与绘图复核
导出前必须做一次叠图复核:把识别出的数据点按映射反算回像素坐标,叠在原图上。如果点偏离曲线,先别改导出格式,回查二值化和标定。CSV 列名用x,y或x_data,y_data,不要用中文列名,后面 Python、Excel 都好接。
% 数据点反算回像素坐标,叠在原图上复核 dataXY = toData(ptsSorted); xBack = xpix(1) + (dataXY(:, 1) - xData(1)) * (xpix(2) - xpix(1)) / (xData(2) - xData(1)); yBack = ypix(3) - (dataXY(:, 2) - yData(1)) * (ypix(3) - ypix(4)) / (yData(2) - yData(1)); figure; imshow(img); hold on; plot(xBack, yBack, 'g.', 'MarkerSize', 6); title('识别点叠图复核:绿点应落在曲线上'); hold off; % 导出 CSV,保留六位小数 writematrix(dataXY, 'curve_points.csv');逻辑说明:xBack、yBack是映射的逆运算,用来把数据坐标还原成像素坐标。绿点偏离曲线说明标定或骨架有问题,不要直接拿 CSV 去拟合。writematrix导出数值矩阵,默认逗号分隔,六位小数对大多数截图够用;需要更高精度时用writematrix(dataXY, 'curve_points.csv', ' Precision', 10)这种形式,具体参数名按你的 MATLAB 版本查文档。叠图复核这一步别省,它是曲线图片识别程序最便宜的质量保险。
5. 曲线图片识别避坑:六个翻车点从现象到解决
曲线识别脚本跑通不难,难的是结果看起来对、用起来错。这一章记录六个高频翻车点,每条按“现象、原因、解决”写,都是我在不同图上踩过的血泪经验。你不需要全部记住,遇到对应现象时回来查一行就行。
5.1 现象:识别结果整体平移
现象:导出的数据曲线形状对,但整体往左或往右偏半个刻度,拟合斜率也跟着偏。原因:x 轴锚点取在绘图区边缘,而不是刻度线起点;或者xData填的是第一个刻度值,但锚点点在了轴线最左端。解决:重新取 x 轴左端时,放大图片,点在第一个刻度线与轴线的交点中心,不要点轴线端点;然后用 3.4 节的网格残差校验,残差超过半个小刻度就回查。
% 平移排查:用已知网格点算偏差 checkPix = [300, 500]; trueXY = [3.0, 2.0]; predXY = toData(checkPix); offset = predXY - trueXY; fprintf('x 偏差 %.4f, y 偏差 %.4f\n', offset(1), offset(2));这段只做诊断,不自动修。offset(1)明显大于offset(2)时,优先查 x 轴锚点;两个方向都偏,查图片是否被裁剪或缩放。解决平移没有捷径,锚点必须落在刻度定义的位置,不能凭感觉点轴线边缘。
5.2 现象:曲线在交叉处被“抄近路”
现象:两条曲线交叉后,识别点从一条曲线跳到另一条,导出数据在交叉点附近出现尖刺。原因:二值化后两条曲线粘成一个连通域,骨架化在交叉点形成分叉,路径追踪选了错误分支。解决:彩色图先按颜色分组,黑白图在交叉点附近手动分段,或者用列扫描加连续性约束,禁止相邻列 y 跳变超过阈值。
% 列扫描连续性约束:相邻列 y 跳变过大时标记为可疑 dy = abs(diff(ySorted)); jumpIdx = find(dy > 40); fprintf('发现 %d 处大跳变,检查交叉点\n', numel(jumpIdx));阈值 40 是像素经验值,按曲线斜率调。大跳变不一定是错,垂直段也会跳,所以要结合叠图看。黑白交叉图不要指望全自动,手动把交叉点前后分成两段,分别扫描,再按 x 拼接,稳定性高得多。
5.3 现象:抗锯齿边缘导致双线
现象:骨架化后一条曲线变成两条平行细线,或者骨架在旁边多出一条毛刺。原因:截图有抗锯齿,曲线边缘有半透明过渡像素,Otsu 阈值把过渡带也判成前景。解决:二值化前先做轻度高斯滤波,或者提高阈值让过渡带落到背景;形态学开运算核加大到 2 到 3 像素,去掉边缘毛刺。不要用太大闭运算,否则两条线粘回去更粗。
% 抗锯齿处理:轻度高斯滤波后再二值化 grayBlur = imgaussfilt(grayD, 0.8); bwBlur = ~imbinarize(grayBlur, graythresh(grayBlur)); bwBlur = bwareaopen(bwBlur, 40);imgaussfilt的 0.8 是标准差,越小越保留边缘,越大越平滑。抗锯齿严重时试 1.0 到 1.2,但别超过 1.5,否则细曲线会糊掉。处理完还是双线,就回看原图分辨率,低分辨率截图的抗锯齿没有太多信息可恢复,只能接受误差。
5.4 现象:批量处理时结果对不上
现象:单张图跑得好好的,批量跑十张,有几张数据整体缩放或坐标轴对不上。原因:图片尺寸不一致、截图裁剪范围不同、坐标轴刻度范围不同,但脚本复用了同一套锚点和xData。解决:批量前先按尺寸分组,同尺寸同版式的图复用锚点,不同尺寸的图重新标定或按比例换算;每张图记录图片宽高、锚点像素、数据范围,出问题能追溯。
% 批量日志:每张图记录尺寸和标定参数 logCell = {'file', 'width', 'height', 'x1', 'x2', 'y1', 'y2'}; files = dir('*.png'); for k = 1:numel(files) im = imread(files(k).name); logCell(end+1, :) = {files(k).name, size(im,2), size(im,1), ... xpix(1), xpix(2), ypix(3), ypix(4)}; end writecell(logCell, 'batch_log.csv');这段是日志骨架,实际批量时每张图的锚点要单独存,不能全用xpix。日志的价值在于出问题后能快速定位是哪张图、哪个参数变了,而不是靠回忆。
5.5 现象:导出数据振荡
现象:CSV 里的 y 值在相邻点之间来回跳,拟合曲线毛刺很多。原因:骨架点没有排序,或者同一列有多个骨架点被中位数压成了错误值;也可能是毛刺没有剪干净。解决:先按 x 排序,再做一次中值滤波或滑动平均;毛刺用bwmorph('spur', n)剪掉,n 按毛刺长度设。不要用大窗口平滑,会把真实峰谷抹平。
% 导出前排序与轻度平滑 [~, ord] = sort(dataXY(:, 1)); dataSorted = dataXY(ord, :); ySmooth = movmedian(dataSorted(:, 2), 3); dataOut = [dataSorted(:, 1), ySmooth];movmedian窗口 3 只压单点跳变,适合毛刺;窗口 5 以上会改变曲线形状,慎用。排序按 x 做,多值曲线不能这么排,会破坏回环。振荡的根因通常在骨架质量,平滑只是补救,不要把它当常规步骤。
6. 从能跑到敢用:曲线识别结果的误差评估与批量脚本习惯
曲线图片识别程序交付前,我一般做两件事:叠图复核和误差指标。叠图看形状,指标看数值,两个都过才敢把 CSV 给别人。重建曲线叠图最直观,把识别点按逆映射画回原图,偏一点都能看出来。误差指标用已知网格点算 RMSE 和最大偏差,采样密度看每单位 x 有多少个点,点太稀拟合会丢峰谷。
% 重建叠图 + 误差指标 dataXY = toData(ptsSorted); xBack = xpix(1) + (dataXY(:,1) - xData(1)) * (xpix(2) - xpix(1)) / (xData(2) - xData(1)); yBack = ypix(3) - (dataXY(:,2) - yData(1)) * (ypix(3) - ypix(4)) / (yData(2) - yData(1)); figure; imshow(img); hold on; plot(xBack, yBack, 'g.', 'MarkerSize', 5); hold off; checkPred = toData(checkPix); rmse = sqrt(mean((checkPred(:) - checkTrue(:)).^2)); maxErr = max(abs(checkPred(:) - checkTrue(:))); fprintf('RMSE=%.4f, MaxErr=%.4f, 采样密度=%.2f 点/单位x\n', ... rmse, maxErr, numel(xSorted) / (xData(2) - xData(1)));| 检查项 | 合格线 | 不达标时先查 |
|---|---|---|
| 叠图偏差 | 绿点基本落在曲线上 | 锚点、轴向反转 |
| 网格 RMSE | 小于半个小刻度 | 锚点位置、xData 起点 |
| 采样密度 | 每单位 x 不少于 20 点 | 骨架断裂、阈值过高 |
| 相邻点跳变 | 无持续大跳变 | 交叉点、毛刺未剪 |
批量脚本我固定三个习惯:每张图单独存标定参数、每张图输出一张叠图、日志里记录图片尺寸和锚点。这样后面发现某张图不对,不用重跑全部,直接看日志和叠图就能定位。从那以后我每次批量处理前都强制先跑三张样图,确认版式一致再放开全量,这个习惯省下的返工时间比调阈值多得多。希望帮到你。
本文还有配套的精品资源,点击获取