资讯动态

MATLAB曲线图片识别:从像素到CSV数据表

发布时间:2026/10/26 13:27:19 来源:尧图企业网站定制
简介面向科研人员与MATLAB图像处理学习者的曲线图片识别工具聚焦从论文截图或实验图像中自动提取曲线坐标减少重复编程与手工描点。压缩包共14个文件以12张PNG示例图像为主另有MAT数据文件与M脚本整体约459KB可用于对照测试识别流程与数据生成。已有2019人学习下载。程序围绕灰度化、二值化、噪声处理、边缘检测、连通域标记与轮廓追踪展开并涉及polyfit等拟合方法及plot绘图展示读者可据此理解从图像读取、坐标提取到曲线重绘的完整链路。示例图片与数据文件适合直接运行和调参便于比较不同预处理与边缘检测参数对识别结果的影响也能作为论文曲线复现、实验数据整理和GUI化改造的基础脚本。1. 曲线图片识别程序把一张死图还原成可用的数据表手里只剩一张从报告里截下来的曲线图原始数据早不知道丢哪去了重做一遍实验又要两周而你要的其实只是曲线上那几十个点的数值。这个基于 matlab 的曲线图片识别程序就是为这种场面准备的输入一张图输出一份 csv 表横轴纵轴按你给的刻度和量程换算成真实物理量。它适合三类人要从旧报告和文献里回收数据的工程人员、手上只有图没有表的实验记录整理者、想把“读图”这一步塞进自己 matlab 处理流程里的开发者。代码是纯 .m 脚本装好 matlab 直接跑不需要编译也不需要额外装第三方库。2. 项目全景与技术选型一条从像素到数据表的处理链路2.1 六个阶段的数据流整套识别流程可以拆成六个阶段每个阶段的输出就是下一个阶段的输入任何一步出问题都会沿着链路一路放大。先把这条链看清楚后面调参的时候才知道该在哪一环动手。阶段输入输出主要函数图像读入png/jpg 文件单通道灰度矩阵imread、rgb2gray、im2double预处理灰度矩阵逻辑型二值掩膜medfilt2、adaptthresh、bwareaopen坐标标定二值掩膜 已知点仿射变换对象ginput、fitgeotrans曲线分离二值掩膜单条曲线的掩膜imopen、bwconncomp逐列采样单条曲线掩膜像素坐标序列bwmorph、find、median坐标换算与导出像素坐标序列csv 数据表transformPointsForward、writetable第一个阶段看着最没技术含量实际上坑最多。手机拍的图、PDF 截的图、扫描件、带 alpha 通道的 pngimread 读进来的数据形态各不相同。彩图要 rgb2gray索引图要先 ind2rgb十六位深度的图直接 im2double 会得到 0 到 1 之外的数值。这套代码在入口统一做了收敛不管读进来是什么出去之前必须是一个 double 型的单通道矩阵值域在 0 到 1 之间。后面所有函数都只认这个格式省得每个函数里都写一遍类型判断。第二个阶段做的是“把曲线从背景里剥出来”。这里的核心矛盾是既要保留细曲线的连续性又不能把 JPEG 压缩噪声、纸张纹理、扫描灰尘一并当成前景。所以参数上永远是两头拉扯调完这个那个又坏了得有耐心。第三个阶段是整条链路里唯一需要人工介入的地方。你告诉程序“图像上这个像素点它在物理世界里是 (0, 0)”和“那个像素点是 (10, 5)”程序据此推算出整张图的坐标映射关系。这一步做完后面所有的像素坐标都能一对一翻译成物理量。第四个阶段处理“图里不止一条线”的情况。实验图里经常有对照组、多组数据、拟合线连通域分析可以按面积把它们切开但两条线一旦交叉就会被粘成一块这时候要么按颜色通道拆要么人工圈定 ROI 分区处理。第五个阶段是最有讲究的。曲线在图像里是一条有宽度的带子三个像素宽、五个像素宽都有可能你要的是一个代表点。逐列扫描取中位数是最朴素也最稳的做法前提是曲线本身是单值的——每一个 x 只对应一个 y。碰上闭合圆环、螺旋这种多值曲线就得换成沿骨架做路径追踪代码结构完全不同。第六个阶段负责把像素序列过一遍仿射变换再做平滑、去重、写文件。2.2 目录结构与函数分工工程不复杂但函数拆得比较细每个文件只管一件事改起来不用满屏找。curve_reader/ ├── main.m % 主入口按顺序串起整条流程 ├── config.m % 全部可调参数的集中地改参数只改这里 ├── io/ │ ├── loadImage.m % 读图、转灰度、统一成 double │ └── exportData.m % 写 csv / xlsx附带单位说明 ├── preprocess/ │ ├── denoise.m % 中值滤波 形态学去噪 │ └── binarize.m % 自适应阈值二值化 ├── calib/ │ ├── detectAxisBox.m % 投影法自动找坐标轴框 │ ├── pickPoints.m % 人工点选标定点 │ └── buildTransform.m % 生成仿射变换并做残差校验 ├── extract/ │ ├── removeGrid.m % 形态学开运算剔除网格线 │ ├── splitCurves.m % 连通域拆分多曲线 │ └── sampleCurve.m % 骨架化 逐列采样 ├── post/ │ ├── smoothCurve.m % Savitzky-Golay 平滑 │ └── dedupPoints.m % 按 x 去重、按步长抽稀 └── samples/ └── demo_curve.png % 一张带网格的示例图用来跑通流程config.m值得单独说一句。做图像处理最怕的就是参数散落在七八个函数里调一次要翻五个文件。这个工程把所有阈值、窗口大小、开关都收在一个结构体里主流程里只传cfg任何函数都不写死数字。二次开发的时候加一个新参数就是在 config 里加一行其余代码不用动。main.m的内容很短基本就是一句一句往下调function result main(imgPath, cfg) % 主流程读图 - 预处理 - 标定 - 分离 - 采样 - 导出 gray io.loadImage(imgPath); bw preprocess.binarize(preprocess.denoise(gray, cfg), cfg); % 先尝试自动找轴框失败就转人工点选 try box calib.detectAxisBox(bw); catch warning(自动检测坐标轴失败切换到人工点选); box []; end tf calib.buildTransform(box, cfg); % 内部会做残差校验 bw extract.removeGrid(bw, cfg); % 网格线必须在采样前去掉 masks extract.splitCurves(bw, cfg); result cell(numel(masks), 1); for k 1:numel(masks) pix extract.sampleCurve(masks{k}, cfg); result{k} post.dedupPoints(post.smoothCurve(pix, tf, cfg), cfg); end io.exportData(result, cfg); end顺序上有一点必须强调removeGrid要放在buildTransform之后、sampleCurve之前。放前面会破坏轴框的直线结构导致自动检测失败放后面则网格线已经被当成曲线采进去了白干。2.3 为什么在 MATLAB 里做这件事选 matlab 不是什么情怀是很实在的工程判断。图像在 matlab 里就是一个二维或三维矩阵灰度化是加权求和二值化是逻辑比较找连通域是bwconncomp全是现成函数。同样的流程用 C 写光接一个 OpenCV 就得先折腾环境写完之后调一次参数还要重新编译。交互这块也是加分项。ginput让用户直接在图上点两下取坐标drawrectangle圈一个 ROIimshowpair做前后对比这些交互在 matlab 里都是一行代码的事。曲线标定这种“必须有人看一眼”的环节用 matlab 做原型的成本远低于写一个带界面的桌面程序。代价也说清楚matlab 部署到没有授权的机器上很麻烦要么装 Runtime 打包要么整个流程换成 python。另外大图处理速度一般一张 4000×3000 的扫描件跑完整条流程要几秒到十几秒做批处理得留够时间。所以这套代码的定位很明确单张、离线、需要人参与判断的场合。真要上产线做几万张的批量识别这套结构可以当原型但底层得换。3. 图像预处理与坐标轴标定识别精度的地基3.1 灰度化、去噪与二值化的参数取舍预处理这一步的目标很单纯让曲线变成“唯一的、连续的前景”其它全是背景。听着简单做起来要连过三关。第一关是去噪。手机拍的图有椒盐噪点PDF 截的图有抗锯齿造成的灰边扫描件有纸张纹理。中值滤波对这些颗粒状的干扰特别有效而且不像高斯滤波那样会把边缘糊掉——曲线本身只有两三个像素宽一糊就断。function gray denoise(gray, cfg) % 中值滤波压掉椒盐噪点和压缩块窗口一般取 3 gray medfilt2(gray, [cfg.medSize cfg.medSize]); % 轻度高斯平滑抹掉抗锯齿产生的灰边 if cfg.gaussSigma 0 gray imgaussfilt(gray, cfg.gaussSigma); end endmedSize取 3 就够取 5 的时候细曲线会被啃掉一段尤其是斜线。gaussSigma默认给 0.6比 1 小属于“意思一下”的量级。第二关是二值化。这里千万别用全局阈值graythresh光照不均的扫描件用全局阈值必翻车左边一片全黑右边曲线没了。自适应阈值按局部邻域算阈值能跟着亮度走。function bw binarize(gray, cfg) % 邻域大小必须是奇数太大会退化成全局阈值 blk 2 * floor(cfg.blkSize / 2) 1; % ForegroundPolarity 设 dark曲线是暗色画在亮背景上 T adaptthresh(gray, cfg.sens, ... ForegroundPolarity, dark, ... NeighborhoodSize, blk); bw gray T; % 暗于局部阈值即判为前景 bw bwareaopen(bw, cfg.minArea); % 清掉零星噪点 bw imclose(bw, strel(disk, 1)); % 补上曲线上的细小断口 endcfg.sens是灵敏度0 到 1 之间越大判为前景的像素越多。0.5 是常见起点噪声多就往下调到 0.4曲线偏淡就往上调到 0.6。cfg.blkSize控制局部邻域的尺寸经验值是图像短边的 1/20 左右太小会把曲线内部误判成背景曲线变成空心的太大又退化成全局阈值。参数典型值作用调大的后果调小的后果medSize3中值滤波窗口去噪更强细线被抹断噪点残留gaussSigma0.6高斯平滑强度边缘糊曲线变粗抗锯齿灰边保留sens0.4~0.6自适应灵敏度前景增多噪声混入曲线断裂、变细blkSize短边/20奇数自适应邻域退化为全局阈值曲线空心化minArea20~50连通域面积下限虚线曲线被整段删掉灰尘点残留第三关是形态学收尾。imclose用半径 1 的圆盘做闭运算能把曲线上一个像素宽的断口补上代价是曲线整体粗一圈——这没关系后面骨架化会把它再削回中心线。bwareaopen则负责清掉面积小于minArea的孤立点。这两个操作的顺序不能反先删小点再闭运算否则相邻的噪点会被连成一条假曲线。调完这三关正确做法是把原图和二值结果叠在一起看一眼figure; imshowpair(gray, bw, blend); title(叠加检查彩色部分为判定的前景);用blend模式比并排显示直观得多曲线一旦在某个局部断掉一眼就能看见不用等到最后导出数据才发现缺了一段。提示调参阶段不要直接看二值图。二值图只有黑白两色人眼很难判断“这条线到底粗了还是细了”。叠加到灰度底图上误差会变得非常明显。3.2 坐标轴检测与标定点确定像素到物理量的换算本质上是在求一个从图像坐标系到数据坐标系的变换矩阵。求这个矩阵需要已知点已知点从坐标轴上来。先说自动检测。带完整轴框的图表横轴和竖轴在二值图上表现为贯穿全图的长直线用投影法就能找到。function box detectAxisBox(bw) % 行投影统计每一行上的前景像素个数 rowProj sum(bw, 2); colProj sum(bw, 1); H size(bw, 1); W size(bw, 2); % 轴线在投影曲线上是尖锐的峰值长度超过图像尺寸的 60% rows find(rowProj 0.6 * W); cols find(colProj 0.6 * H); if isempty(rows) || isempty(cols) error(未检测到完整轴框需人工标定); end box.rowTop min(rows); box.rowBot max(rows); box.colLeft min(cols); box.colRight max(cols); end这个方法的前提是轴框完整且水平垂直图像一旦旋转超过两三度投影峰值就会摊平阈值过不去。所以自动检测失败是很正常的事代码里用 try-catch 兜住直接落到人工。人工标定的做法是在图上点两个已知坐标的点。为什么是两个而不是四个因为fitgeotrans的nonreflectivesimilarity模式只需要两组点就能定出平移、旋转和缩放对大多数扫描件足够了。图像如果有明显的透视畸变比如斜着拍的照片才需要三个点走affine。function [pixPts, dataPts] pickPoints(gray, n) % 在图上依次点选 n 个已知坐标的点 figure(Name, 标定按提示点选); imshow(gray); hold on; title(sprintf(请依次点选 %d 个已知坐标的点, n)); pixPts zeros(n, 2); dataPts zeros(n, 2); for k 1:n pixPts(k, :) ginput(1); % 返回 [x y]即 [列 行] plot(pixPts(k,1), pixPts(k,2), r, MarkerSize, 14, LineWidth, 2); dataPts(k, :) input(sprintf(第 %d 点的物理坐标 [x y], k)); end hold off; end这里有个血泪经验点选一定要放大之后再点。imshow出来的默认视图是缩放的鼠标落点和真实像素之间可能差好几个像素而坐标轴的线本身有两三个像素宽点在线的上边缘还是下边缘换算出来的数据会整体偏一个固定量。稳妥的做法是先zoom on放大到能看清单个像素再取轴线的中心位置。3.3 像素坐标到物理坐标的仿射映射拿到标定点之后剩下的就是拟合变换矩阵。function tf buildTransform(box, cfg) if ~isempty(box) % 自动模式把轴框的四个角当作已知点 pixPts [box.colLeft box.rowBot; box.colRight box.rowBot; box.colRight box.rowTop; box.colLeft box.rowTop]; dataPts [cfg.xRange(1) cfg.yRange(1); cfg.xRange(2) cfg.yRange(1); cfg.xRange(2) cfg.yRange(2); cfg.xRange(1) cfg.yRange(2)]; mode affine; else % 人工模式两个点足够定出相似变换 [pixPts, dataPts] calib.pickPoints([], 2); mode nonreflectivesimilarity; end tf fitgeotrans(pixPts, dataPts, mode); % 残差校验反算回像素坐标看偏了多少 back transformPointsInverse(tf, dataPts(:,1), dataPts(:,2)); resid hypot(back(:,1) - pixPts(:,1), back(:,2) - pixPts(:,2)); if max(resid) 2 warning(标定点最大残差 %.2f 像素建议重新点选, max(resid)); end endfitgeotrans的第一个参数是图像上的点moving第二个参数是对应的物理坐标fixed方向搞反了会得到一个逆变换导出的数据全是错的而且不会报错——这种玄学问题最耗时间。判断方法很简单拿一个明显偏左下的像素点丢进transformPointsForward出来的物理坐标应该也是偏小的一端。affine能同时处理平移、旋转、缩放和错切四个点以上是超定方程matlab 会用最小二乘拟合点选误差会被平摊掉一部分。nonreflectivesimilarity只处理平移、旋转、等比缩放两个点就够但对非等比拉伸无能为力——如果图表的 x 轴和 y 轴比例尺不一样很常见为了让曲线好看用这个模式出来的数据会被整体压扁。残差校验那几行别省。标定点没点准后面所有的数据都会带一个系统性偏差而且这个偏差从曲线上完全看不出来只有拿已知点反算才能暴露。残差超过 2 个像素说明至少有一个点选歪了。4. 曲线提取与数据还原从骨架到序列点4.1 网格线剔除与曲线分离大多数实验图表都带网格线。网格线在二值图上是一堆贯穿绘图区的水平长线和垂直长线如果不处理逐列采样时会同时采到网格线和曲线取中位数得到的是一条毫无意义的中间值。形态学开运算是剔除长直线的标准手段用一个细长的结构元素去“腐蚀再膨胀”只有比结构元素更长的线段才能存活下来。function bwCurve removeGrid(bw, cfg) % 结构元素长度必须大于网格线的实际长度 seH strel(line, cfg.gridLen, 0); % 水平方向 seV strel(line, cfg.gridLen, 90); % 垂直方向 gridMask imopen(bw, seH) | imopen(bw, seV); % 只剩网格线 bwCurve bw ~gridMask; % 从原图里减掉 bwCurve bwareaopen(bwCurve, cfg.minCurveArea); endcfg.gridLen的取值直接决定成败。取小了网格线被完整保留采样结果全乱取大了曲线里那些接近水平的平坦段比如饱和区、基线会被一起当成网格线删掉曲线上出现一段莫名其妙的空白。经验值是网格线间距的 1.5 倍左右——先用imshow配ginput量一下相邻两条网格线隔多少像素乘个 1.5 填进去。这个操作还有个副作用网格线和曲线的交叉点会被一并削掉曲线上留下一个个小缺口。所以removeGrid之后要跟一次imclose补洞或者干脆放到断点修补那一步统一处理。多曲线的拆分用连通域function masks splitCurves(bw, cfg) cc bwconncomp(bw, 8); % 8 邻域连通 stats regionprops(cc, Area, PixelIdxList); keep find([stats.Area] cfg.minCurveArea); masks cell(numel(keep), 1); for k 1:numel(keep) m false(size(bw)); m(stats(keep(k)).PixelIdxList) true; masks{k} m; end end按面积过滤能解决大部分问题但两条曲线一旦交叉它们就变成一个连通域会被当成一条线采出来。碰上这种情况按颜色分是更可靠的路子原图是彩色的就转 HSV 空间把不同色相的范围分别取出来当掩膜各条曲线在颜色上天然可分根本不依赖连通性。4.2 骨架、逐列采样与断点修补拿到单条曲线的掩膜之后先做骨架化把三五个像素宽的带子削成一像素宽的中心线。function pts sampleCurve(mask, cfg) skel bwmorph(mask, skel, Inf); % 提取单像素骨架 skel bwmorph(skel, spur, 3); % 剪掉三个像素以内的毛刺 [rows, cols] find(skel); uCols unique(cols); xs zeros(numel(uCols), 1); ys zeros(numel(uCols), 1); for k 1:numel(uCols) c uCols(k); r rows(cols c); xs(k) c; ys(k) median(r); % 一列里有多个点断口、噪声时取中位数 end pts [xs ys]; endskel和thin都能做骨架化前者迭代到没有可删的像素为止后者保证结果不产生断点。斜向的细线用thin更稳代价是速度慢一些。spur那一步剪毛刺别省——骨架化在曲线的拐点、分叉处容易长出几个像素的短枝这些短枝会让某一列的中位数突然偏出去最终曲线上出现一个尖刺。逐列扫描的核心假设是曲线单值每个 x 只有一个 y。做函数图像、光谱曲线、示波器波形都没问题碰上闭合曲线就废了。这时候的替代方案是沿骨架做八邻域追踪从一个端点出发一路走到另一个端点得到的是按弧长排序的点序再做坐标换算。代码结构差别挺大得单独实现一个traceCurve.m。断口修补要看断在哪儿step diff(uCols); bigGap find(step cfg.maxGap); % 大断口真的断了不插值 smallGap find(step 1 step cfg.maxGap); % 小断口补齐网格线剔除留下的缺口就属于这一类 for k smallGap gapCols (uCols(k)1) : (uCols(k1)-1); gapRows round(interp1([uCols(k) uCols(k1)], ... [ys(k) ys(k1)], gapCols, linear)); pts [pts; gapCols gapRows]; %#okAGROW end pts sortrows(pts, 1);cfg.maxGap一般设成 5 到 10 个像素。小于这个宽度的断口基本是处理过程造成的网格线剔除、形态学腐蚀插值补上是合理的大于这个宽度多半是曲线本身就有间断比如分段测量的数据强行插值会凭空造出一段不存在的趋势比留着缺口更糟。4.3 平滑、去重与导出原始采样点一定带锯齿。原因不是算法不行而是图像本身离散曲线在像素网格上走的是阶梯状路径每一列的 y 只能取整数采出来的序列天然有 ±1 像素的抖动换算到物理量可能是 ±0.05 单位。function data smoothCurve(pts, tf, cfg) % 先换算到物理坐标再平滑平滑窗口按物理量取更直观 [xData, yData] transformPointsForward(tf, pts(:,1), pts(:,2)); % Savitzky-Golay保形能力优于滑动平均不会把峰值压平 order cfg.sgOrder; % 多项式阶数一般 2 frame cfg.sgFrame; % 窗口长度必须为奇数 yData sgolayfilt(yData, order, frame); data [xData yData]; end用sgolayfilt而不是movmean是因为滑动平均在曲线的极值点附近会把峰值压平——本来是个尖峰平滑完变成一个矮包。Savitzky-Golay 本质是在滑动窗口内做多项式拟合对二次以下的曲线能原样保留峰位和峰高都不会跑。窗口长度取 7 到 11太短起不到平滑作用太长会把真实的细节波动也抹掉。这个函数属于 Signal Processing Toolbox如果目标机器上没装就自己写一个滑动窗口的最小二乘拟合二十行代码的事。去重和抽稀也要做function data dedupPoints(data, cfg) % 按 x 排序并去重同一列采到多个点时会出现重复 x [~, ia] unique(round(data(:,1), 6), first); data data(sort(ia), :); % 按最小 x 间隔抽稀去掉过度密集的点 keep [true; diff(data(:,1)) cfg.minDx]; data data(keep, :); end去重那一步必须做。同一列里如果有多个骨架点曲线的拐弯处经常出现median只返回一个值看起来没事但断口修补插入的点可能和原点重合导出的 csv 里就会出现两个 x 完全相同的行下游做插值或者绘图的时候直接报错。round(..., 6)是为了避免浮点误差导致本该相同的两个 x 被判定为不同。导出用writetable顺手把单位写进表头function exportData(results, cfg) for k 1:numel(results) T array2table(results{k}, VariableNames, ... {[x_ cfg.xUnit], [y_ cfg.yUnit]}); outName sprintf(%s_curve%d.csv, cfg.baseName, k); writetable(T, fullfile(cfg.outDir, outName)); end end表头带上单位半年后回来看这份 csv 才知道那一列是毫米还是英寸。5. 常见问题排查识别结果对不上的六个真实原因调这套流程的时候出错的方式就那么几种每一种都有明确的症状。下面这六条是踩得最多的按“现象 → 原因 → 解决”记下来下次遇到直接对号入座。5.1 图像与标定环节的坑现象一曲线出来全是毛刺比原图锯齿还厉害。原因基本可以锁定在二值化。灵敏度sens给高了自适应阈值把 JPEG 压缩块、纸张纤维、抗锯齿灰边统统判成了前景骨架化之后这些噪点和主干连在一起采样出来的 y 值上下乱跳。解决分三步走。先把sens从 0.5 降到 0.4看毛刺有没有减少再把minArea从 20 提到 50 到 100把面积小的孤立块清掉最后检查medSize是不是被改成了 1等于没做中值滤波。诊断的时候不要盯着最终曲线看直接imshowpair(gray, bw, blend)噪点在叠加图上是一片细碎的彩色麻点非常扎眼。注意bwareaopen的阈值不能无脑加大。碰上虚线或者点划线形式的曲线minArea一超整段虚线会被当成噪点删干净曲线上凭空少一截而且这个缺失在二值叠加图上看起来很正常。现象二导出的数据整体平移了一个固定量形状完全正确。这是标定点的问题不是算法的问题。最常见的成因是点在坐标轴线的边缘而不是中心。坐标轴本身有两三个像素宽你点在线的外侧边缘和内侧边缘换算出来的零点差了 2 到 3 个像素映射到物理量就是一个固定偏移。解决办法是放大之后取轴线中点。zoom on之后用ginput取轴线上下两个边缘的坐标取平均再填进标定。更省事的做法是让程序自动算在标定点附近开一个 5×5 的窗口取窗口内所有前景像素的坐标均值这个均值就是轴线中心。现象三x 方向的数据被拉伸或压缩y 方向正常。两种情况。一是用了nonreflectivesimilarity模式而这个图表的 x、y 比例尺本来不一致——相似变换强制等比缩放必然有一边被压。改成affine并提供至少三组点。二是图像本身有旋转或透视而标定只做了简单的比例换算x x0 (col - c0) * kx这种。旋转两三度肉眼几乎看不出来但横向几百个像素累积下来端点的偏差能到十几个像素。判断方法很简单看坐标轴在二值图上是不是严格水平——把上边框所在的行号取出来如果沿着 x 方向行号在缓慢变化就是旋转了。解法是用fitgeotrans加imwarp先把图像校正到轴线水平再走后面的流程。5.2 提取与导出环节的坑现象四曲线的平坦段中间缺了一块。removeGrid里gridLen给大了把接近水平的曲线段当成了网格线一起删掉。饱和区、基线、平台期这类区域最容易中招因为它们在图上就是一条水平长线。诊断方法把gridMask单独imshow出来看一眼。正常情况它应该只有规整的网格如果里面有跟曲线轮廓一致的碎片就是gridLen的问题。解决是把gridLen调到网格线间距的 1.5 倍左右别贪大。调完之后如果曲线上留了缺口交给 4.2 节的断口修补去补。现象五两条曲线变成了一条或者一条变成了好几段。变成一条是连通域问题两条线在某个点交叉8 邻域下它们连成一个连通域splitCurves把它当成一整条。变成好几段则是反过来的情况曲线中间被网格线切断或者被一个噪点大小的空洞打断连通域分成好几块每一块都被当成一条独立的曲线导出一个 csv。前者的解法是按颜色分离原图彩色时或者人工划 ROI。后者要看断口宽度cfg.maxGap设小了就把合理的断口当成真断裂。判断依据是断口两侧的斜率是否连续把断口两端的点连一条直线看中间区域有没有前景像素——有的话说明是连通域被切碎了属误判没有才是真断。现象六导出的点数比预期多了好几倍而且 x 有明显重复。原因有三个。一是没有做 4.3 节的去重同一列采到多个点时unique没生效。二是minDx设得太小抽稀形同虚设。三是骨架化之后曲线上还挂着毛刺每一根毛刺都贡献了一个额外的采样点。先检查去重逻辑把data(:,1)排个序diff之后找等于 0 的位置有就是去重没生效。再检查骨架imshow(skel)放大看曲线的拐点正常骨架是光滑的单线有短枝就是spur那一步没做或者参数给太小。这两步都排除了再调minDx。6. 二次开发与改造把识别流程接进自己的批处理单张图的交互式识别只解决了“救急”的问题。真正常用的场景是一批图——同一台设备导出的几十张曲线截图格式统一、坐标轴位置基本一致一张张点选太浪费。改造思路是把标定环节从交互挪到配置表里。具体做法是先跑一遍手动流程把每张图的标定点像素坐标和对应的物理坐标存成一张表主循环里直接读表不再调用pickPoints。批处理版本的骨架大概是这样function batchRun(cfg) imgList dir(fullfile(cfg.inDir, *.png)); axisTab readtable(cfg.axisFile); % 列文件名、x0、y0、x1、y1 for k 1:numel(imgList) name imgList(k).name; row axisTab(strcmp(axisTab.FileName, name), :); if isempty(row) warning(%s 无标定记录跳过, name); continue; end cfg.imgPath fullfile(cfg.inDir, name); cfg.axisPix [row.Px0 row.Py0; row.Px1 row.Py1]; cfg.axisVal [row.Vx0 row.Vy0; row.Vx1 row.Vy1]; try batchResults{k} main(cfg.imgPath, cfg); %#okAGROW catch ME % 单张失败不影响整批记录原因继续往下跑 warning(%s 处理失败%s, name, ME.message); batchResults{k} []; end end save(fullfile(cfg.outDir, batch.mat), batchResults); end两个细节值得说。一是try-catch必须加在循环内部而不是外部一张图失败不能让整批停掉那些失败的文件名和错误信息才是后续排查的线索。二是标定表要跟着图像一起归档别只存结果不存标定——半年后想复现某张图的结果没有标定点就得重新点一遍。验证方法我用得最顺手的一招是造合成图。找一个解析式已知的函数用plot画出来、按目标分辨率导出成 png再丢进整条流程跑一遍把识别结果和解析解逐点比对。% 造一张“答案已知”的测试图 x linspace(0, 10, 400); y 2.5 * sin(x) 0.3 * x; figure(Position, [100 100 800 500], Color, w); plot(x, y, k, LineWidth, 1.5); axis tight; set(gca, Box, on); print(-dpng, -r150, test_synth.png);跑完之后算最大绝对误差正常情况下应该控制在量程的 1% 以内。超过就说明链路上某一环出了系统性问题本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑