简介:一套MATLAB分形盒维数计算工具包,面向需要使用盒计数法分析一维、二维、三维复杂自相似结构的研究人员与学习者。包内共27个文件,包括可直接运行的.m脚本、示例图片(png/jpg)、动态演示gif以及HTML说明文档,压缩包大小约3.15MB;工具包实现了从生成分形集合、定义盒子尺寸范围,到统计覆盖盒子数并进行对数线性回归拟合的完整计算流程,最终得出维数D。同时覆盖一维科赫曲线、二维曼德勃罗集、三维谢尔宾斯基海绵等典型分形示例,并附带核心计算函数及分形树、阿波罗垫片等可视化样例,便于对比不同维度下的计算差异,使用者可直接运行体验,也可替换为自己的数据。目前已有683人学习/下载,适合作为分形几何与图像分析方向的学习和参考工具。
1. 一个反直觉结论:为什么曲线的维数不是 1
先破个常识:一条理想直线你量出来维数是 1.0,但一条真实的海岸线、一段心电信号、一张多孔材料断面图,你按同一套算法去量,会得到 1.2、1.7、2.3 这样的小数。分形盒维数就是把这套“给曲线、图像、体数据量粗细程度”的算法统一起来。它解决的问题很直接:不管你的数据是 1D 的时序曲线、2D 的二值图像,还是 3D 的 CT 体素,我都能用一个数描述“结构复杂度”,这个数还能跨尺度比较。适合谁?做图像分割评估、材料微观结构分析、医学影像复杂度量化、信号异常检测的人,都会在某个节点遇到“这个结构到底有多碎”的追问。而盒维数几乎是你最快能落地、且不依赖任何训练数据的一个答案。
我第一次真正跑通这个算法,是在处理某高校实验室的孔隙结构数据时。最开始以为要去读一堆分形几何理论,后来发现,盒维数的核心逻辑极其朴素:用不同尺寸的盒子去覆盖数据,数一数非空盒子有多少个,然后取对数做线性回归。换句话说,它就是个“用尺子量复杂度”的计数游戏。下面我按 1D、2D、3D 三种输入,把代码、参数和坑一次讲完。
2. 盒维数算法拆解:一个盒子计数函数打天下
很多人把盒维数想成三种不同算法,其实不是。1D、2D、3D 共享同一个数学定义,只差在“数据怎么落入盒子”这一步。
2.1 定义与数学形式
给定一个有界集合 S,用边长为 ε 的均匀网格覆盖它,统计至少包含一个数据点的格子数量 N(ε)。对不同的 ε 重复这个过程,盒维数定义为:
D = lim_{ε→0} [ log N(ε) / log(1/ε) ]
实际数据不可能取极限,所以我们取一组对数等间隔的 ε,计算对应的 N(ε),然后在 log(1/ε) 对 log N 的坐标里做最小二乘拟合,取斜率的相反数作为维数。斜率是负的,因为 ε 越小,盒子数量越大。
提示:拟合时看的是“斜率”,不是相关系数。有人在汇报时只贴 r²,但 r² 高不代表维数可信,后面避坑章会专门说。
这个定义有三个直接推论,决定了代码怎么设计:
第一,数据必须先归一化,否则 ε 的含义和单位会随输入漂移。我统一把数据各轴按“最大跨度”缩放到 [0,1],保持各向同性。第二,ε 的取值不能随便给,必须在“最小分辨率”和“整体尺度”之间取中间段。第三,统计盒子数量要去重,不是数点数,而是数“不同格子编号的个数”。这三点全部落实,这个算法才算真正可用。
2.2 ε 取值区间:维数计算里最容易被忽略的参数
ε 取小了,盒子比数据点之间的最小间距还密,此时每个点大概率独占一个格子,盒子数 N(ε) 几乎不随 ε 变化,log-log 曲线会变成一条水平线,斜率趋近 0。ε 取大了,所有数据落进一个或少数几个盒子,曲线同样趋向 0。只有中间那段近似直线的区域才反映分形结构。
我一般这样设置区间:
| 参数 | 含义 | 经验值 |
|---|---|---|
| ε_min | 最小盒子边长 | 3~5 倍数据平均最小间距 |
| ε_max | 最大盒子边长 | 数据跨度的 0.2~0.5 |
| n_eps | 回归点的数量 | 8~16 个对数等间隔点 |
这里有一个血泪经验:ε_min 不要小于分辨率。对图像数据来说,分辨率就是 1 像素,ε_min 取 2~3 像素起步,否则图像坐标取整后大量格子计数相同,接近 ε 下限时曲线出现一段“平台”,差分斜率直接塌下来。对连续点云数据,先算所有点的最近邻平均距离,再乘一个系数,比拍脑袋给个 0.01 靠谱得多。
2.3 网格偏移为什么会影响结果
同一个数据,你把网格整体平移半个格子,N(ε) 会变;尤其是稀疏数据,一个点刚好落在格线附近时,归属哪个格子非常敏感。这种抖动在真实数据上会导致维数偏差 0.02 到 0.1 不等。别急着骂算法“玄学”,这是盒维数自带的相位敏感性。解决的办法有两条:一是对同一 ε 做多个偏移量取平均,二是在回归时只取中间段,把两端受网格影响的点去掉。第 5 章我会单独展开。
3. 1D 曲线盒维数:最简 Python 实现与两个版本
一维输入最常见的形式是时序信号:电压波形、振动信号、股价序列。你要回答的问题是:这条曲线在 [0,1] 区间里“卷曲”到什么程度。
3.1 顶点计数版的最小实现
import numpy as np def box_count_1d(points, eps_range=(0.01, 0.5), n_eps=12): # points: shape (M,) 或 (M,2) pts = np.asarray(points, dtype=float) if pts.ndim == 1: # 一维信号转成 (index, value) 点列 pts = np.column_stack([np.arange(pts.size), pts]) # 统一归一化:按最大跨度缩放,保持 x/y 各向同性 pmin = pts.min(axis=0) span = pts.max(axis=0) - pmin scale = span.max() pts = (pts - pmin) / scale epsilons = np.geomspace(eps_range[0], eps_range[1], n_eps) counts = [] for eps in epsilons: # 关键:坐标整除得到盒子编号,再统计唯一盒子 cell = np.floor(pts / eps).astype(np.int64) n_box = len(np.unique(cell, axis=0)) counts.append(n_box) coeffs = np.polyfit(np.log(1.0 / epsilons), np.log(counts), 1) dim = -coeffs[0] return dim这段代码的逻辑全部集中在循环里的两行:np.floor(pts / eps)把连续坐标映射到整数格子编号;np.unique(cell, axis=0)去掉重复格子,统计不同的盒子数量。后面取对数做一阶多项式拟合,维数就是负斜率。
参数上,eps_range可以传(0.01, 0.5),意思是盒子边长从数据跨度的 1% 到 50%;n_eps设为 12,表示在对数尺度上等间隔取 12 个点。为什么上限是 0.5 而不是 1.0?因为盒子边长接近数据跨度时,所有点几乎都在一个盒子里,回归点没有信息量。如果你把eps_range[1]调到 0.9,log-log 曲线后半段会明显弯曲,拟合出的维数偏低。
注意:这个版本假设输入点已经足够密。如果一条折线只有几十个顶点,直接用顶点计数会发现维数趋向 0——因为有限的点没有“粗细”,每个点一个格子,不随尺度变化。这种情况必须用下面的细分版本。
3.2 折线连续化:细分采样版
def densify_polyline(points, min_step): # points: (M,2),折线顶点 # min_step: 细分后相邻点的最小间距 out = [] pts = np.asarray(points, dtype=float) for i in range(len(pts) - 1): seg_len = np.linalg.norm(pts[i + 1] - pts[i]) n_split = max(int(np.ceil(seg_len / min_step)), 1) seg = np.linspace(pts[i], pts[i + 1], n_split + 1) out.append(seg[:-1]) out.append(pts[-1:]) return np.vstack(out)细分之后再把结果丢进box_count_1d。关键在于min_step必须小于你计划用eps_range[0]的值,比如你准备从 0.01 开始回归,细分间距至少取 0.005,否则细分后仍然落在同一个盒子里,等于没细分。
实际操作里,我一般用一个经验判断:先算原始折线的平均段长,如果平均段长小于 ε_min,说明原始采集密度足够,直接用顶点版;否则先细分再算。这个判定逻辑同样适用于 2D 边缘线提取和 3D 网格边界,一劳永逸。
4. 2D 与 3D 盒维数:图像和体数据的计数写法
图像和体数据本身就是离散栅格,天然适合盒子计数。但实现上有两个容易翻车的点:一是行列坐标顺序,二是 3D 数据的内存表达。
4.1 2D 二值图像:从轮廓图到盒维数
def box_count_2d(img_bool, eps_range=(0.01, 0.5), n_eps=12): # img_bool: shape (H, W) 的布尔数组,True 表示前景 ys, xs = np.nonzero(img_bool) pts = np.column_stack([xs, ys]) # 注意转成 (x, y) 顺序 return box_count_1d(pts, eps_range, n_eps)为什么特意把xs放前面?因为 1D 版本里points的列是 (x, y),图像np.nonzero返回的是 (row, col),即 (y, x),不调换会引入一个 90 度的旋转。虽然存成 (y, x) 也算一种坐标系统一,但图像宽高不一致时,归一化结果会不同,回归斜率也会跟着变。统一用 (x, y) 是省心的习惯。
2D 数据里最常见的误用是拿灰度值直接当坐标。灰度图像的每个像素表示强度,如果你把 (x, y, intensity) 当 3D 点云去算盒维数,得到的是一个混合了灰度分布和信息量的维数,不是轮廓的几何维数。做分割评估时,通常先二值化,再对这个布尔掩膜计数。只有当你刻意分析“灰度起伏的表面”时,才把强度当成第三维坐标。
4.2 3D 体数据:稀疏坐标才是内存救星
3D 体素数组如果直接构造完整布尔张量,例如 (1024, 1024, 1024),内存占用约 1GB,还没开始跑就已经把资源吃掉了。正确做法是用np.nonzero拿到稀疏坐标,再用和 2D 完全一致的计数逻辑:
def box_count_3d(voxel_bool, eps_range=(0.01, 0.5), n_eps=12): zz, yy, xx = np.nonzero(voxel_bool) pts = np.column_stack([xx, yy, zz]) # 统一到 (x, y, z) return box_count_1d(pts, eps_range, n_eps)这段代码在只有几万个前景体素时瞬间跑完。如果前景体素数量达到几百万级别,np.unique(cell, axis=0)会产生一个大的中间数组,此时需要注意内存峰值。我一般会分块处理:把坐标数组切成若干块,每块独立统计唯一盒子,再合并去重。但这个优化对大多数医学影像和材料数据不做也无妨。
4.3 3D 表面和多孔结构的实际选择
3D 输入有两种截然不同的对象:实体填充结构和薄壳表面。同一个立方体,填充的盒维数接近 3.0,表面接近 2.0。算之前要问清楚:你的体素标记里,前景表示“属于材料的体素”还是“材料边界面”?如果是表面,建议先在体素上做一次边缘提取,得到一层薄壳,再计算;否则大量内部体素会把维数推向 3,掩盖表面的分形特征。
对于孔隙结构,还有一种稳定做法:对每一层切片分别算 2D 盒维数,然后取中位数和四分位距。这样做的优点是规避 3D 网格偏移问题,同时能看出结构在不同方向上的复杂度差异。缺点是体素数据各向异性严重时,片层方法会引入方向偏差——这时优先用完整 3D 盒维数。
5. 盒维数计算中的五个常见坑:现象、原因、解决
坑 1:不管什么输入,算出来都接近数据维度
现象:1D 信号算出来维数 0.x,2D 图像算出来 0.x 或接近 1,而不是期望的 1.x~2.x。
原因:你在统计盒子数量时用了len(pts),也就是数据点数,而不是len(np.unique(cell, axis=0))。当 ε 小于点间距时,每个点独立占一个盒子,N(ε) 不再下降,回归斜率趋近 0。如果 ε 范围选得过大,所有点在一个盒子里,斜率也会趋近 0。更隐蔽的原因是把数据点当成了“点集”来算——有限个点的盒维数永远是 0,只有连续的曲线/曲面才有几何维数。
解决:先画 log-log 曲线,检查中间段是否近似直线,端点是否明显弯曲。用唯一格子计数。对连续形状先细分采样,再进计数函数。
坑 2:log-log 曲线两端都往下弯,中间段很短
现象:拟合出的维数偏小,r² 虽然大于 0.99,但换一个 ε 范围结果变化剧烈。
原因:ε_min 太小和 ε_max 太大同时存在。小 ε 端盒子比分辨率还密,大 ε 端数据聚集到少数盒子,两种效应都会让曲线斜率趋于 0。回归被非分形区间污染。
解决:不要直接对全区间回归。先把 ε 范围缩到中间段,例如eps_range=(0.02, 0.2),然后看曲线是否近似线性;再逐步扩展范围,直到端点开始明显弯曲为止。这个操作我之前做过一版全自动代码,后来发现不如“人工看曲线 + 自动回归”稳,因为不同数据的分形区间差异很大。
坑 3:图像宽高相差悬殊,维数结果异常
现象:一张 (1000, 100) 的长条形图像,算出的维数与旋转 90 度后明显不同。
原因:归一化时如果各轴独立缩放到 [0,1],长宽比例被强行拉伸,几何结构发生了各向异性变形。盒维数对尺度变换不是等距不变的,各向异性缩放会改变结构的相对复杂度。
解决:统一按最大跨度缩放,即scale = span.max(),所有轴除以同一个 scale。这是我代码里一直保持各向同性的原因。如果你明确研究的是“各向异性分形”,那按各轴独立缩放也可以,但要意识到你定义的维数已经不同,且无法和文献值直接对比。
坑 4:数据平移几个像素,维数变化超过 0.05
现象:同一图像,在画布上左移 2 个像素,盒维数从 1.72 变成 1.65。
原因:网格相位效应。盒子网格固定,数据整体平移导致统计的格子归属变化,稀疏结构尤其明显。
解决:可以做一个多偏移量平均。对每个 ε,把数据坐标分别加 0、0.25、0.5、0.75 倍 ε 的偏移,统计四次 N(ε) 取平均,再回归。代价是计算量变成四倍,但维数稳定性显著提升。对 3D 数据,偏移量要扩展到三个轴共 8 个组合,效果更明显。
坑 5:3D 数组直接建完整布尔体,内存直接被吃光
现象:代码跑到一半被杀进程,或机器卡死。
原因:把稀疏体素表示成完整三维布尔数组,内存随尺寸三次方增长。尤其是医学影像重建后,数组动辄上千的三次方。
解决:用np.nonzero提取前景坐标,转成 (N,3) 的紧凑数组,再进入统一的计数逻辑。如果前景点超过千万级,对坐标分块去重,最后合并计数结果。
6. 用已知维数的形状验证实现:一个值得养成的习惯
算法写完,别急着跑真实数据。先用一组理论维数已知的标准形状验一遍,确认没有系统性偏差。我日常用的验证集如下:
| 测试数据 | 理论盒维数 | 期望区间 |
|---|---|---|
| 单位线段(连续采样 10000 点) | 1.0 | 0.98 ~ 1.02 |
| 单位正方形(二值填充) | 2.0 | 1.95 ~ 2.05 |
| 单位立方体(体素填充,分辨率 256³) | 3.0 | 2.95 ~ 3.05 |
| 康托尔三分集(迭代 10 层) | log 2 / log 3 ≈ 0.6309 | 0.60 ~ 0.66 |
| Sierpinski 三角形(迭代 8 层) | log 3 / log 2 ≈ 1.585 | 1.54 ~ 1.63 |
我的习惯是写一个validate()函数,把上面五个用例跑一遍,输出每个维数和误差。一旦发现线段维数偏离超过 0.02,先检查 ε 区间和归一化,再检查计数是否有重复。这几个用例都通过后,真实数据的结果我才敢写入报告。对真实数据,我还会额外做一个稳定性检查:把数据随机去掉 20% 的点,再算一次维数,两次结果偏差应小于 0.05。如果偏差过大,大概率是 ε 下限没避开点间距,或者数据本身不够密。
最后说一个收尾技巧:真实数据的 log-log 曲线几乎不可能完美线性,回归时可以只看中间连续的一段,并记录起止 ε。这样换数据时也能保持可比性——同样是 1.72,是“从 0.02 到 0.2 拟合出来的 1.72”,还是“从 0.05 到 0.3 拟合出来的 1.72”,含义完全不同。等真实数据积累多了,你会发现大部分分形现象只在有限的 1.5 个数量级里成立。看清楚这段范围再汇报,比纠结 r² 到小数点后三位有用得多。
希望帮到你。
本文还有配套的精品资源,点击获取