从手绘曲线到可变厚度遮罩:几何算法与MATLAB实现详解
1. 从“手绘曲线”到“可变厚度遮罩”的核心挑战在图像处理或者交互式图形应用中我们经常会遇到一个看似简单但实现起来颇有门道的问题用户用鼠标或触控笔在屏幕上自由地画了一条开放的、不闭合的曲线然后希望基于这条曲线生成一个遮罩Mask。这个遮罩不是简单的单像素线而是具有一定“厚度”的甚至这个厚度在曲线的不同位置还可以变化。比如你想模拟一支真实的画笔笔触的粗细会随着压力或速度变化或者你想在医学图像上沿着一条手绘的路径高亮显示一个具有一定宽度的感兴趣区域。这个需求听起来很直观但当你真正动手去实现时会发现它涉及从交互逻辑、几何计算到像素级操作的一系列问题。核心的挑战在于“开放的自由曲线”本身是一个一维的路径而“可变厚度的遮罩”是一个二维的像素区域。如何将前者优雅、准确且高效地转换为后者就是我们要解决的核心问题。网络上相关的讨论往往聚焦于某个特定工具如MATLAB的imfreehand的用法但背后的原理和通用实现思路才是更值得深挖的干货。2. 理解基础工具imfreehand,impoly与createMask在深入可变厚度遮罩之前我们先厘清几个基础概念这有助于理解更复杂需求的实现路径。这些术语常出现在像MATLAB的Image Processing Toolbox这样的环境中但其思想是通用的。2.1imfreehand捕获自由形态imfreehand是一个交互式工具它允许用户在图像上通过点击和拖拽来绘制一条任意形状的、闭合或开放的曲线。其核心输出是一系列有序的坐标点(x, y)这些点描述了用户鼠标移动的轨迹。对于开放曲线起点和终点不重合。注意imfreehand创建的是一个“可拖动”的图形对象其本身并不是遮罩。你需要从该对象中提取位置信息。2.2impoly定义多边形区域与imfreehand的“自由绘制”不同impoly用于创建和编辑一个多边形。用户通过点击来定义多边形的顶点。它天然是用于定义闭合区域的。虽然你也可以用很多点来近似一条曲线但其交互模式是点-点连接而非连续拖拽。2.3createMask从图形对象到二值图像createMask是一个方法它作用于像imfreehand或impoly创建的图形对象上。它的功能非常明确根据该对象当前在图像坐标系中的形状生成一个与之大小相同的二值逻辑矩阵即遮罩。对于impoly定义的多边形createMask会生成一个内部为1白色/真外部为0黑色/假的遮罩。对于imfreehand如果曲线是闭合的其行为类似多边形填充如果曲线是开放的标准的createMask会将其视为一个非常细长的、几乎无面积的多边形生成的遮罩可能几乎全为0或者只有单像素宽的线取决于实现和抗锯齿这显然不是我们想要的“有厚度的遮罩”。因此直接对一条开放的imfreehand曲线使用createMask无法得到可变厚度的遮罩。我们必须自己动手基于曲线路径坐标构建我们想要的遮罩区域。3. 构建遮罩的核心算法从路径到区域既然基础工具不直接支持我们就需要设计算法。整个过程可以分解为几个关键步骤我将结合原理和伪代码来解释。3.1 步骤一路径采样与表示首先我们从交互工具如imfreehand获取一系列离散的坐标点P [p1, p2, ..., pn]其中pi (xi, yi)。这些点是鼠标采样得到的可能分布不均匀。为了后续计算稳定我们通常需要进行路径重采样确保点与点之间的欧氏距离大致相等。% 伪代码简单线性插值重采样 function resampled_P resamplePath(P, desired_spacing) cumulative_dist 计算P中相邻点的累计距离; new_parametric_locations 在总距离上按desired_spacing等间距采样; resampled_P 根据new_parametric_locations对P进行线性插值得到新点集; end均匀采样的路径是我们后续定义“厚度”的基准线。3.2 步骤二计算路径法线要在路径两侧“扩张”出厚度我们需要知道每个路径点处的“侧向”方向即法线方向。对于二维平面曲线在某一点的法线方向垂直于该点的切线方向。计算切线对于路径点pi其切线向量ti可以通过前后相邻点差分来近似。例如中心差分ti (p(i1) - p(i-1)) / 2。对于端点可以使用前向或后向差分。计算法线将切线向量(dx, dy)逆时针旋转90度得到单位法线向量ni (-dy, dx) / sqrt(dx^2 dy^2)。这个ni指向路径的“一侧”例如左侧。另一侧的法线就是-ni。% 伪代码计算路径点法线 function normals calculateNormals(P) tangents zeros(size(P)); for i 2:length(P)-1 tangents(i, :) P(i1, :) - P(i-1, :); end % 处理端点 tangents(1, :) P(2, :) - P(1, :); tangents(end, :) P(end, :) - P(end-1, :); normals zeros(size(tangents)); for i 1:length(tangents) dx tangents(i, 1); dy tangents(i, 2); length_t sqrt(dx^2 dy^2); if length_t 0 % 单位法线逆时针旋转90度 normals(i, :) [-dy, dx] / length_t; else normals(i, :) [0, 0]; % 对于重复点法线无定义 end end end3.3 步骤三应用可变厚度这是实现“可变厚度”的关键。我们需要为路径上的每个点pi定义一个厚度值ri可以是半径也可以是单侧厚度。这个厚度序列可以来自用户输入如绘制时的压力数据也可以是一个函数如根据曲率变化。对于每个点pi和其法线ni我们可以得到该点处用于构建遮罩的两个边界点上边界点p_upper_i pi ri * ni下边界点p_lower_i pi - ri * ni将所有的p_upper_i和p_lower_i分别连接起来就得到了两条包围原始路径的曲线它们之间的区域就是我们的目标遮罩区域。注意如果厚度ri变化剧烈直接连接这些边界点可能会产生自相交或尖刺需要考虑平滑处理。3.4 步骤四区域填充与栅格化现在我们有了两条边界曲线上下边界它们与原始路径的首尾端点共同形成了一个近似闭合的多边形。这个多边形可能不是凸的。我们的目标是将这个多边形内部的所有像素标记为1外部标记为0。构造多边形顶点序列通常我们可以按顺序连接上边界点再逆序连接下边界点形成一个闭合的多边形顶点列表V。使用多边形填充算法最常用的是扫描线填充算法。对于图像中的每一行扫描线计算该行与多边形所有边的交点然后对这些交点按x坐标排序两两配对之间的像素就是需要填充的内部像素。MATLAB 实现捷径在MATLAB中我们可以利用poly2mask函数。将多边形顶点V的x坐标和y坐标分别作为输入并指定生成遮罩的图像尺寸poly2mask会高效地完成栅格化填充。% 伪代码生成最终遮罩 function mask createVariableWidthMask(P, normals, radius_array, image_size) % P: 重采样后的路径点 Nx2 % normals: 法线向量 Nx2 % radius_array: 每个点处的半径厚度 Nx1 % image_size: [height, width] upper_boundary P radius_array .* normals; lower_boundary P - radius_array .* normals; % 构造多边形顶点序列上边界 - 下边界逆序- 回到起点 polygon_vertices [upper_boundary; flipud(lower_boundary); upper_boundary(1, :)]; mask poly2mask(polygon_vertices(:,1), polygon_vertices(:,2), image_size(1), image_size(2)); end4. 高级议题与实战避坑指南掌握了基本算法在实际编码中你还会遇到几个典型的“坑”。这里分享我的实战经验。4.1 路径端点的处理上面的方法在路径中部效果很好但在起点和终点多边形是强行闭合的这会导致端头形状是平的像被切断了一样很不自然。为了模拟真实的画笔笔触我们希望端头是圆形的。解决方案在路径的起点和终点额外添加“端帽”。一个简单有效的方法是在起点p1处沿着与起始切线垂直的方向即法线方向以r1为半径画一个半圆或整圆。在终点pn处同理。将这个端帽多边形的顶点加入到总的polygon_vertices中。% 伪代码添加圆形端帽 function vertices_with_caps addRoundCaps(P, normals, radius_array, start_cap, end_cap) vertices []; if start_cap theta linspace(-pi/2, pi/2, 10); % 在法线两侧各90度范围内采样 start_normal normals(1, :); cap_vertices P(1, :) radius_array(1) * [cos(theta), sin(theta)] * [start_normal; -start_normal]; % 需要根据法线旋转坐标 vertices [vertices; cap_vertices]; end % 添加主路径边界顶点... if end_cap % 类似地处理终点角度范围可能是 pi/2 到 3*pi/2 % ... vertices [vertices; cap_vertices_end]; end vertices_with_caps vertices; end4.2 厚度剧烈变化与自相交当相邻点的半径ri和r(i1)相差很大时上下边界点连线后可能形成非常尖锐的角甚至导致边界线自相交。自相交会使poly2mask产生不可预料的结果如空洞或奇异形状。解决方案厚度平滑在生成半径数组radius_array后对其进行低通滤波如移动平均、高斯滤波避免相邻点厚度突变。使用更鲁棒的轮廓生成方法不直接连接上下边界点而是将每个路径点pi及其半径ri视为一个“圆盘”。目标遮罩就是所有这些圆盘的并集。这可以通过距离变换来实现先创建一个和图像一样大的空矩阵D初始值为无穷大。对于每个圆盘计算图像中每个像素到该圆盘中心pi的距离如果小于半径ri则更新D为该较小距离。最后遮罩mask D 0或者D 某些阈值。这种方法天然避免了自相交并且端头也是圆形的但计算量比多边形方法大。MATLAB中的bwdist应用可以先生成一个只包含路径中心线的单像素宽遮罩使用poly2mask或bwmorph然后利用bwdist计算其距离变换。但这种方法难以直接实现可变厚度因为bwdist给出的是到最近中心线像素的等距扩张。要实现可变厚度需要更复杂的加权距离变换或逐点处理。4.3 性能优化大图像与长路径如果图像很大如4K或路径非常长采样点很多多边形顶点数量会激增导致poly2mask计算变慢。优化策略路径简化在重采样后使用道格拉斯-普克算法等简化算法在允许的误差范围内减少路径点数。这对自由手绘曲线尤其有效能去除大量冗余点。下采样计算如果遮罩精度要求不是极高可以先将路径坐标按比例缩放在较小的尺寸下生成遮罩然后再用imresize放大回原图尺寸。imresize使用插值放大后的遮罩边缘会有点模糊但通常可以接受。并行计算如果采用“圆盘并集”或距离变换的方法并且有大量独立的曲线要处理可以考虑使用parfor循环需要Parallel Computing Toolbox。5. 完整实现流程与代码框架将上述所有步骤整合一个健壮的、支持可变厚度开放曲线的遮罩生成函数框架如下function mask freehandCurveToMask(path_points, radius_func, image_size, varargin) % path_points: Nx2 来自imfreehand或其他交互工具的原始点 % radius_func: 函数句柄输入为点索引归一化距离原始点坐标等输出该点半径。或直接是半径数组。 % image_size: [rows, cols] % varargin: 可选参数如smoothing平滑系数cap是否添加端帽 % 1. 解析输入参数 p inputParser; addParameter(p, smoothing, 0.1); addParameter(p, cap, true); parse(p, varargin{:}); % 2. 路径预处理重采样、平滑 resampled_points resamplePathUniformly(path_points); if p.Results.smoothing 0 resampled_points smoothPath(resampled_points, p.Results.smoothing); end % 3. 计算法线 normals calculateNormals(resampled_points); % 4. 计算每个点的半径 if isa(radius_func, function_handle) t linspace(0, 1, size(resampled_points, 1)); % 归一化路径长度 radii arrayfun((i) radius_func(i, t(i), resampled_points(i,:)), 1:size(resampled_points,1)); radii radii(:); else radii radius_func(:); % 假设是数组 end % 可选对半径进行平滑 radii smooth(radii, 5); % 5. 生成边界点 upper resampled_points radii .* normals; lower resampled_points - radii .* normals; % 6. 构造多边形顶点考虑端帽 if p.Results.cap [upper, lower] addRoundCapsToBoundaries(upper, lower, normals, radii); end polygon_x [upper(:,1); flipud(lower(:,1))]; polygon_y [upper(:,2); flipud(lower(:,2))]; % 7. 栅格化填充 mask poly2mask(polygon_x, polygon_y, image_size(1), image_size(2)); % 8. 可选后处理去除可能因计算误差产生的孤立小点 mask bwareaopen(mask, 5); end这个框架提供了很高的灵活性。radius_func可以是常数函数固定厚度、根据绘制速度变化的函数速度慢半径大、甚至是从硬件读取的压力数据映射的函数。6. 超越基础与Mask R-CNN思想的碰撞文章开头提到了“Mask R-CNN”这个热词。虽然Mask R-CNN是用于实例分割的深度学习模型但其“Mask”生成的思想与我们这里的几何生成有异曲同工之妙。Mask R-CNN通过一个全卷积网络为每个目标预测一个低分辨率如28x28的掩码然后通过双线性插值上采样到原图尺寸。这本质上是一个从稀疏、高层表示到稠密像素映射的过程。在我们的问题中路径点序列和半径序列就是一种高层、稀疏的表示。我们的算法多边形填充或距离变换就是确定的“解码器”将这个稀疏表示“上采样”为稠密的像素级遮罩。理解这种“表示-解码”的范式有助于我们将问题抽象化。例如你可以训练一个简单的神经网络输入是图像和几个路径点输出是半径序列从而实现智能的、基于图像内容的可变厚度笔画预测这就在传统图像处理和深度学习之间架起了一座有趣的桥梁。最后实现这样一个功能最深的体会是对“边界”的处理决定了效果的精致程度。无论是端帽的圆形、厚度变化处的平滑过渡还是避免自相交的稳健算法功夫都花在如何让生成的二维区域看起来是自然、连续、由一条一维曲线“生长”出来的。这不仅仅是数学和代码更是一种对视觉表现的追求。