☰
MATLAB曲线图片识别程序:从论文截图中自动提取数据点
2026/10/16 21:52:46 网站建设 项目流程

简介:一款基于MATLAB的曲线图片识别程序,主要面向需要从科研图像中自动提取曲线坐标的研究人员与工程技术人员。该工具能够对图片进行灰度化、二值化、去噪等预处理,并通过边缘检测、轮廓追踪和曲线拟合等步骤,将论文或实验图像中的曲线数据快速还原为可分析绘图的数据点,有效避免重复造轮子的低效工作。压缩包共14个文件,以12张png示例图为主体,辅以1个mat数据文件和1个m源程序,整体大小仅459KB,结构简洁便于上手。目前已有2018人学习,适合具备一定MATLAB基础、希望掌握图像处理与曲线提取流程的读者。资源附带了多组不同形态的曲线示例图片、可直接运行的MATLAB源程序以及配套的数据存储文件,可对照代码熟悉im2gray、edge、regionprops、polyfit等关键函数的使用方法,并迁移到自己的数据处理任务中。

1. 曲线图片识别程序:从论文截图里把数据点抠出来

做科研复现时,最头疼的一件事是:论文里的曲线图没有配套数据表,想拿人家结果跟自己算法对比,只能对着截图手动取点。放大、对准、记坐标,一张图几十个点搞一晚上,费眼费神还带主观误差。这个MATLAB程序就是干这个的——把一张曲线图片(论文截图、示波器导出图、手绘草图都行)自动识别出曲线像素位置,再通过标定点映射成真实数据坐标,最后导出一份可以直接用的数据文件。它适合有MATLAB环境、需要从图表反向提取数值的研究者或工程师,也适合想学习图像处理基础流程的入门开发者。整个项目从图像预处理到数据导出大概三百行代码,核心逻辑清晰,是典型的用MATLAB做数字图像处理的落地案例。

2. 整体架构与文件结构:一个脚本为什么拆成五个模块

2.1 技术选型:为什么用MATLAB而不是OpenCV

这个项目最开始是用Python写的,后来换成了MATLAB,原因挺实际:识别曲线这事本质是图像形态学操作加数值插值,MATLAB的Image Processing Toolbox里imbinarize、bwmorph、regionprops这几个函数直接覆盖了八成需求,不需要自己造轮子。而且处理二维矩阵的语法比Python列表推导直观得多,调试时可以随手imshow看一眼中间结果,这对图像类项目来说太重要了。

另一个考虑是目标用户。这个项目的典型使用场景是实验室里复现论文数据,而科研场景MATLAB占有率很高。分发一个.m脚本合集,对方只要装了就F5跑起来,比让他去配Python环境、装opencv和numpy要省事得多。当然代价也有,后面会提到——MATLAB的图像函数版本差异是个隐藏的坑。

2.2 目录结构与模块职责

项目解压后的目录布局是这样的:

curve_ocr/ main.m # 主脚本,编排整个流程 preprocess/ gray_and_binary.m # 灰度化 + 二值化 + 反相校正 remove_grid.m # 网格线和边框清理 extract/ trace_curve.m # 逐列扫描提取曲线像素坐标 skeleton_trace.m # 骨架化追踪(多值曲线备用方案) calibrate/ pick_axis_points.m # 交互式选取标定点 map_coords.m # 像素坐标到数据坐标的映射 export/ write_csv.m # 导出CSV文件 verify_fit.m # 画出对比图验证提取效果 test/ sample_curve.png # 测试样例:带网格的论文折线图 case_loglog.png # 对数轴样例 README.md

模块拆分的核心逻辑是:每个文件夹对应一类操作,preprocess管"把图片变成干净的二值图",extract管"从二值图拿到曲线像素轨迹",calibrate管"从像素还原到数据",export管"落地验证"。这样做的直接好处是,当你拿到一张特别脏的截图时,可以在main.m里注释掉remove_grid单独调试预处理环节,不用在几百行脚本里翻找。

2.3 主脚本main.m的运行序列

main.m本身不写具体算法,只做流程编排和参数设置。看一下关键序列:

%% main.m clear; close all; clc; img_path = 'test/sample_curve.png'; img_orig = imread(img_path); %% 第一步:预处理,得到干净的二值曲线图 img_bin = preprocess.gray_and_binary(img_orig); img_bin_clean = preprocess.remove_grid(img_bin); %% 第二步:提取曲线像素轨迹 [px_x, px_y] = extract.trace_curve(img_bin_clean); px_x = px_x(:); px_y = px_y(:); %% 第三步:交互式标定,选坐标轴原点和一个已知点 disp('请在图上依次点击:坐标原点、X轴末端已知点、Y轴末端已知点'); axis_pts = calibrate.pick_axis_points(img_orig); %% 第四步:映射到数据坐标 data_xy = calibrate.map_coords(px_x, px_y, axis_pts); %% 第五步:导出和验证 export.write_csv('result_curve.csv', data_xy); export.verify_fit(img_orig, data_xy, axis_pts);

主脚本的思路是把交互环节单独抽出来。pick_axis_points在figure上用ginput等用户点击,这一步暂停程序等待人工输入,其余步骤全部自动。参数调整集中在脚本头部,比如需要改二值化阈值,改的是gray_and_binary内部的参数,而非主脚本。

3. 核心模块拆解:一张图片变成一串像素点的完整链路

3.1 灰度化与二值化:阈值方向是个隐形开关

预处理第一步是把彩色图或灰度图转成二值图,MATLAB里核心函数是imbinarize。注意这里不要用老掉牙的im2bw,它在R2016a之后已经标记为不建议使用,而且阈值语义和imbinarize不兼容。看代码:

function img_bin = gray_and_binary(img_orig) % 灰度化:彩色图转灰度 if size(img_orig, 3) == 3 img_gray = rgb2gray(img_orig); else img_gray = img_orig; end % 自适应二值化:Otsu方法自动确定阈值 img_bin = imbinarize(img_gray, 'global'); % 也可以用 adaptive % 反相校正:确认曲线是白色前景、背景为黑色 % 计算前景像素占比,如果前景超过50%,说明二值化方向反了 if mean(img_bin(:)) > 0.5 img_bin = ~img_bin; end end

逻辑说明:rgb2gray按亮度加权把RGB转成灰度,权重是[0.2989, 0.5870, 0.1140],对绿色分量权重最高,这是因为人眼对绿色最敏感。imbinarize(img_gray, 'global')用Otsu方法自动算阈值,把灰度图分成前景背景两类,这个方法在曲线和背景亮度差异明显时效果稳定。

参数说明:'global'字符参数指定全局阈值;如果把该行的'global'换成'adaptive',就会用局部邻域阈值处理亮度不均的图片,比如扫描件边缘发暗的图。反相校正是个血泪经验——有的图曲线是黑线白底,二值化后前景变成了背景,如果不加这个自动判断,后面所有步骤都会拿到一个"负片",提取结果完全错乱。

3.2 去除网格线:连通域分析锁定长线段

论文截图里最普遍的干扰就是网格线。网格在二值图里是一堆横竖直线,和曲线交织在一起。这里用bwmorph和regionprops做两轮过滤:

function img_clean = remove_grid(img_bin) % 第一步:用形态学开运算去掉细小的孤立噪点 img_clean = bwareaopen(img_bin, 80); % 移除面积小于80像素的对象 % 第二步:标记连通域并计算形状特征 cc = bwconncomp(img_clean); stats = regionprops(cc, 'Area', 'MajorAxisLength', 'MinorAxisLength', 'Eccentricity'); % 第三步:过滤掉长条状的直线对象(网格线) for k = 1:cc.NumObjects len = stats(k).MajorAxisLength; ecc = stats(k).Eccentricity; % 网格线特征是极长且接近标准椭圆形状 if ecc > 0.995 && len > 300 img_clean(cc.PixelIdxList{k}) = 0; end end % 第四步:闭运算把曲线断口接上 img_clean = imclose(img_clean, strel('line', 5, 90)); img_clean = imclose(img_clean, strel('line', 5, 0)); end

逻辑说明:bwareaopen按8连通域面积过滤,80这个值是把扫描产生的孤立墨点、噪点清掉。接着regionprops对每个连通域求主轴长度和偏心率——网格线是一条很长的直线,偏心率接近1(eccentricity=0是圆,=1是直线),长度往往超过图像宽度的三分之一。这两个条件同时满足的就判定为网格线,直接置零清除。

这里有个参数调整技巧:len > 300这个阈值要参考你的图片尺寸,如果图片是2000像素宽,300会放过一部分短网格,如果图片是500像素宽,300可能误杀曲线。我一般会在调试时用stats打印前20条连通域的len分布,人工看一眼阈值该划在哪。闭运算的strel('line',5,90)是沿着垂直方向做5像素长的闭运算,把曲线因为网格遮挡产生的断点接续起来,90表示角度,0表示水平方向。

3.3 逐列扫描提取曲线像素坐标

这是整个项目最核心的一段代码。思路非常朴素:对于单值函数曲线(x轴方向上每个x只对应一个y),从上到下逐列扫描,记录每一列第一个遇到的前景像素的行号,这个行号就是该x位置处的曲线纵坐标像素值。

function [px_x, px_y] = trace_curve(img_bin) [H, W] = size(img_bin); px_x = []; px_y = []; for col = 1:W % 取当前列的所有像素 col_data = img_bin(:, col); % 找到该列所有前景像素的行索引 row_inds = find(col_data); if ~isempty(row_inds) % 取最上方的点(适用于实线曲线) y_pixel = row_inds(1); px_x(end+1) = col; px_y(end+1) = y_pixel; else % 该列没有曲线像素,记录NaN占位 px_x(end+1) = col; px_y(end+1) = NaN; end end % 去掉两端无效的NaN,避免边界噪声 valid = ~isnan(px_y); px_x = px_x(valid); px_y = px_y(valid); end

逻辑说明:find(col_data)返回该列所有值为1的元素的行号,row_inds(1)取第一个也就是最上面的点。为什么取最上面?因为用MATLAB画图时曲线是在白色背景上,像素坐标的y轴向下增长,曲线粗细一般是1到3个像素,取顶端能保持轨迹稳定。如果这一列完全没有前景像素,说明曲线在这里有断口或超出边界,用NaN占位,后续可以插值补上。

参数说明:这个算法对单值函数曲线(比如y=f(x)的折线图、频谱图)有效。如果遇到的是多值曲线,比如圆、椭圆、回滞环,逐列扫描会丢掉一半数据点,这时要改用同目录下的skeleton_trace.m——先用bwmorph(img_bin, 'skel', Inf)把曲线细化成单像素骨架,再用8邻域追踪把整个连通域的像素坐标全部输出。两种方案在同一输出接口下互换,主脚本里只需改一行调用函数名。

4. 坐标标定与数据导出:像素坐标变成真实数据的最后一步

4.1 标定点选取:三点击穿坐标系

曲线像素坐标本身没有物理意义,x对应的是图片列数,y对应的是行数。要还原成实际数据,必须知道图片中坐标轴的比例尺和原点位置。这里用的办法是:交互式点击三个点,即原点、X轴已知刻度点、Y轴已知刻度点,然后用仿射映射把像素坐标转换到数据坐标。

function axis_pts = pick_axis_points(img_orig) imshow(img_orig); title('依次点击:原点 -> X轴已知点 -> Y轴已知点(右键结束)'); hold on; % ginput以像素坐标返回点击位置,n=3表示等3个点 [x_px, y_px] = ginput(3); axis_pts.px = [x_px(:), y_px(:)]; % 输入这些点对应的实际数据值 prompt = {'X轴末端点对应数值:', 'Y轴末端点对应数值:', '原点对应数值:'}; default_vals = {'10', '5', '0'}; answer = inputdlg(prompt, '标定数据', [1 30], default_vals); axis_pts.x_axis_end = str2double(answer{1}); axis_pts.y_axis_end = str2double(answer{2}); axis_pts.origin_val = str2double(answer{3}); hold off; end

逻辑说明:ginput(3)阻塞等待用户在图窗上点击三个位置,返回的是像素坐标。注意MATLAB的figure坐标系y轴向下,所以点击得到的y_px是图像行号。随后inputdlg弹窗询问这三个点分别对应的真实数据值,把像素位置和数据数值绑定成映射关系。返回值axis_pts是一个结构体,被后续的map_coords消费。

4.2 仿射映射:两个轴独立缩放

拿到了三对映射点后,x轴和y轴可以分开做线性映射。x方向通过原点像素坐标和X轴末端像素坐标确定缩放比例,y方向通过原点和Y轴末端确定缩放比例。注意y像素坐标需要翻转,因为图片原点在左上角,而数据坐标系原点通常在图左下角。

function data_xy = map_coords(px_x, px_y, axis_pts) % 数值解构 ox_px = axis_pts.px(1,1); % 原点像素x oy_px = axis_pts.px(1,2); % 原点像素y xend_px = axis_pts.px(2,1); % X轴末端像素x yend_px = axis_pts.px(3,2); % Y轴末端像素y ox_val = axis_pts.origin_val; xend_val = axis_pts.x_axis_end; yend_val = axis_pts.y_axis_end; % 像素差转数据差的比例尺 scale_x = (xend_val - ox_val) / (xend_px - ox_px); scale_y = (yend_val - ox_val) / (oy_px - yend_px); % 注意分母的翻转 % 批量转换 data_x = ox_val + (px_x - ox_px) * scale_x; data_y = ox_val + (oy_px - px_y) * scale_y; % 图像y方向取反 data_xy = [data_x(:), data_y(:)]; end

逻辑说明:scale_x是X轴上单位像素对应的数据增量,scale_y的分母(oy_px - yend_px)是负的,因为图像上Y轴末端yend_px在原点上方(行号更小),而数据值更大。这一步的翻转是新手最常见的翻车点——不取反的话提取出的曲线会上下镜像。data_y = ox_val + (oy_px - px_y) * scale_y中,当px_y越小(图像中越靠上)时,oy_px - px_y越大,对应数据y越大,就正确还原了"图像上方=数据值大"的物理含义。

参数说明:当前这个三点击法假设两个坐标轴都是线性刻度。如果是对数坐标图(比如Bode图、log-log图),在调用map_coords前需要把输入的轴末端值取对数:

% 对数轴适配:比如X轴是log刻度 axis_pts.x_axis_end = log10(axis_pts.x_axis_end); % 映射后还原 data_xy(:,1) = 10.^data_xy(:,1);

不加这段处理,对数图提取的数据是严重弯曲的。项目test/case_loglog.png就是专门用来验证这个场景的。

4.3 导出CSV与可视化验证

数据拿到之后不能直接信,先用verify_fit.m把提取的数据点重新叠画到原图上,肉眼检查轨迹是否贴合:

function verify_fit(img_orig, data_xy, axis_pts) figure; imshow(img_orig); hold on; % 把数据坐标映射回像素坐标,叠加显示(反向计算) ox_px = axis_pts.px(1,1); oy_px = axis_pts.px(1,2); scale_x = (axis_pts.x_axis_end - axis_pts.origin_val) / (axis_pts.px(2,1) - ox_px); scale_y = (axis_pts.y_axis_end - axis_pts.origin_val) / (oy_px - axis_pts.px(3,2)); back_x = ox_px + (data_xy(:,1) - axis_pts.origin_val) / scale_x; back_y = oy_px - (data_xy(:,2) - axis_pts.origin_val) / scale_y; scatter(back_x, back_y, 8, 'red', 'filled'); title('红色点为提取结果,检查是否贴合原曲线'); end

逻辑说明:这段代码的核心思想是逆变换验证——把导出的数据坐标再用标定参数映射回图像像素坐标,以散点形式叠加在原图上。如果提取正确,红色散点会精确排列在原始曲线轨迹上;如果有偏差,会直观看到某些点漂移到曲线外,这时回去调二值化参数或标定点位置。

导出CSV的部分用了MATLAB的writetable:

function write_csv(filepath, data_xy) T = array2table(data_xy, 'VariableNames', {'x', 'y'}); writetable(T, filepath); fprintf('数据已导出到:%s,共%d个点\n', filepath, size(data_xy, 1)); end

这里用array2table而不是直接csvwrite,是因为新版本的MATLAB对csvwrite也标记了不建议使用,而且writetable能自动带列名,后续读入别的工具时更友好。

5. 常见问题排查:二值化反相、断线与坐标翻转的血泪记录

5.1 提取出的曲线上下倒置

现象:导出的数据画出来跟原图曲线完全镜像,峰变谷,谷变峰。

原因:map_coords中y轴像素坐标没有翻转。MATLAB的图像坐标系原点在左上角,y值向下增长;而数学坐标系原点通常在左下角,y值向上增长。写了scale_y = (yend_val - ox_val) / (yend_px - oy_px)这种"顺向"公式,就会得到镜像数据。

解决:y方向的映射必须用(oy_px - px_y)作为像素差,从图像上方的Y轴末端到原点,像素行号是减小的,数据值从大到小,一正一负正好抵消还原。在map_coords.m里强制加一条注释提醒:"y_px越小,data_y越大,这是正确的"。

5.2 曲线提取结果断裂成一段一段

现象:trace_curve返回的像素点在某些区间突然消失,曲线图上有明显的缺口,对应位置恰好是网格线覆盖的地方。

原因:网格线比曲线粗,二值化后网格线区域完全覆盖了曲线像素。remove_grid把网格线删掉的同时,也把它覆盖住的曲线像素一并删除了,导致曲线上留下空洞。

解决:两个办法。一是在remove_grid闭运算那步把结构元素从5像素加大到9像素,让闭运算填补更大的缺口。二是提取策略改成先删网格再描曲线,而是在提取时跳过被网格占据的列,改用相邻列的曲线位置插值补上。我一般先用imclose补断口,补不上的局部缺口靠插值:

% 对px_y中的NaN做线性插值 nan_pos = isnan(px_y); px_y(nan_pos) = interp1(px_x(~nan_pos), px_y(~nan_pos), px_x(nan_pos), 'linear');

5.3 二值化后曲线变成背景、背景变成曲线

现象:预处理后imshow看到的是黑白反转的图,原本的黑线变白底、白底变黑线,提取结果完全乱套。

原因:某些论文图表是白底深色曲线,imbinarize的Otsu方法把深色像素识别为前景,得到的二值图是"黑线白底",导致逐列扫描时每一列都从顶部就开始记录前景像素。

解决:前面gray_and_binary里的自动反相判断就是为这个准备的。mean(img_bin(:)) > 0.5这个条件是启发式的——正常二值化后曲线只占图面很少比例,前景像素占比远低于50%。如果高于50%,基本可以确定前景背景反了,直接~img_bin翻转。但要注意:如果原图曲线很粗、占图面比例很大(比如粗笔画手绘图),这个启发式可能误判,需要人工看一眼二值化结果再决定是否注释掉翻转。

5.4 MATLAB版本差异导致函数报错

现象:在旧版本(R2016a之前)跑imbinarize直接报错"Undefined function",或者在新版本跑im2bw提示不建议使用。

原因:im2bw、bwlabel等老函数被逐步淘汰,imbinarize、bwconncomp是替代方案。版本差异在MATLAB里尤其明显,因为Image Processing Toolbox的API演进比较激进。

解决:项目代码里只用新版API。如果你的环境是旧版,降级写法是:

% 旧版兼容写法(不推荐但可用) level = graythresh(img_gray); img_bin = im2bw(img_gray, level);

但bwconncomp在旧版不存在,只能用bwlabel加regionprops组合,这属于比较费劲的兼容改造。我的建议是直接用MATLAB Online或升级到R2020b之后的版本,省去不必要的兼容成本。

5.5 ginput点击标定点时不认点

现象:pick_axis_points弹出了图片,但点击三次后程序没有继续执行,窗口好像卡住了。

原因:ginput在等待时会开启figure的鼠标交互,但如果你先用imshow显示图片后没有hold on,或者figure被其他窗口遮挡,点击事件可能没有正确注册。更常见的原因是inputdlg弹窗被系统拦截,卡在弹窗等待而未注意。

解决:在ginput(3)之前加figure(gcf)把当前图窗置前,并且ginput的返回值会在右键或回车时提前终止——如果右键点了,x_px长度不足3,后续索引会越界。稳健写法是加一个循环判断,直到收集满3个点才继续:

while size(pts, 1) < 3 [x_tmp, y_tmp] = ginput(1); pts(end+1, :) = [x_tmp, y_tmp]; plot(x_tmp, y_tmp, 'rx'); % 回显已选点 end

这样即使误操作右键,也能继续补齐点,不会莫名其妙中断流程。

6. 二次开发方向:把脚本改造成带界面的批量取数工具

脚本化方案的优点是逻辑透明,缺点是每处理一张图都得改路径、调参数、手动标定。如果想要更顺手的体验,一个比较实用的改造方向是给它套一个MATLAB App Designer界面。改造思路并不复杂,核心是把main.m里五个步骤封装成独立函数(这部分项目已经做到了),然后在App里拖五个按钮分别对应"加载图片"、"预处理预览"、"提取像素"、"标定导出"、"批量处理"。加载图片用uigetfile返回文件路径传给imread,预处理预览用axes组件显示中间结果。这样从命令行脚本变成图形界面工具,对不熟悉MATLAB命令行操作的同事也更友好。

另一个值得做的改造是批量处理。论文复现场景经常是同一篇文章里有五六张折线图,每张图单独跑一遍标定确实累。批量思路是先对每张图做预处理和曲线提取,保存像素坐标和该图片文件名到内存表格,然后一次性对所有图做标定导出:

% 批量处理伪代码 files = dir('paper_figs/*.png'); for i = 1:length(files) img = imread(fullfile(files(i).folder, files(i).name)); img_bin = preprocess.gray_and_binary(img); img_bin = preprocess.remove_grid(img_bin); [px_x, px_y] = extract.trace_curve(img_bin); % 暂存px坐标 raw_result(i).name = files(i).name; raw_result(i).px = [px_x(:), px_y(:)]; end % 对每张图分别标定 for i = 1:length(raw_result) img = imread(fullfile(files(i).folder, raw_result(i).name)); axis_pts = calibrate.pick_axis_points(img); data_xy = calibrate.map_coords(raw_result(i).px(:,1), raw_result(i).px(:,2), axis_pts); export.write_csv(fullfile('output', [raw_result(i).name(1:end-4) '_data.csv']), data_xy); end

你也可以顺手加一个插值重采样模块,让导出的数据点按固定的x间隔(比如每隔0.1均匀采样),这在进行数值积分或频域分析时特别有用。

这个项目我最大的教训是:别跳过预处理的可视化确认。之前图快,把二值化和网格清除一口气跑完直接提取,结果某张图片因为扫描倾斜,网格线没有被完整识别,曲线轨迹混入大量网格残留点,导出的数据画出来是一条锯齿带状的东西。从那以后我每次拿到新类型的图表,都强制先看一遍二值化中间结果、再跑一遍叠加验证,确认没问题才继续下一张。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询