简介:这份压缩包为2026年华中杯数学建模竞赛B题“反射的艺术”的完整参赛资料,面向参赛学生、指导教师及对光反射建模感兴趣的研究者。包内共含17个文件,总大小约4.01MB,包括两个Python脚本(圆柱镜面反射模拟与配图生成)、LaTeX论文源文件及PDF成品、9张结果图(如映射网格变形、参数灵敏度、反射角分布等)和3个drawio解题思路图,便于对照论文逐项复现与二次开发。论文正文约40页,从问题背景、模型假设出发,运用微分方程描述反射行为、图论分析传播路径并结合优化算法求解,代码带有注释,配合图表可快速理解建模与验证全过程。目前该资源已有219人浏览学习,适合需要快速获取完整赛题方案、深入理解反射建模细节的读者。
1. 反射的艺术,比你想的更吃数值
“反射的艺术”这六个字放在华中杯B题里,第一眼以为是几何题,真正上手才发现它是“最优化+物理建模”的复合题。光线打到一个表面,入射角等于反射角,初中物理一句话的事,但一旦放进竞赛卷子,往往就变成:给一堆离散点、给一个不规则反射面、要你找反射点、要你算最短路径、还要你证明“为什么是这个点而不是旁边那个点”。核心不是原理难,是建模和求解的稳定性难。这道题适合做得动几何约束、愿意把优化器调明白的队伍;只会套最小二乘或纯暴力网格搜索的队伍,大概率会在精度和速度两个指标上集体翻车。
我按“物理模型先立住、数值求解再跟上、论文图表最后补”的顺序,把一条可完整跑通的路线拆开讲,代码附带关键参数说明,最后是我自己踩过的几个坑,照着躲能省半天时间。
2. 从光学定律到优化问题:先写出能收敛的数学模型
2.1 反射定律的矢量形式:别再用角度凑
大多数队伍上来就写 (\theta_i = \theta_r),然后用反三角函数去凑角度,这在平面镜场景下能用,但题目一旦出现曲面或需要程序化判方向,角度法会引入一堆分支判断,还容易出现角度跨象限导致的符号错误。我一般直接用矢量形式:
入射方向单位向量 (\mathbf{d}_{in}),表面法向单位向量 (\mathbf{n})(指向入射一侧),反射方向:
[ \mathbf{d}{out} = \mathbf{d}{in} - 2(\mathbf{d}_{in}\cdot\mathbf{n})\mathbf{n} ]
这个公式的好处是无分支、无三角运算、对任意三维方向都成立,而且恰好就是“镜面反射”的精确表达。代码实现只有三行:
import numpy as np def reflect(d_in, n): # d_in, n 均为单位向量;n 指向入射侧 d_in = d_in / np.linalg.norm(d_in) n = n / np.linalg.norm(n) d_out = d_in - 2 * np.dot(d_in, n) * n return d_out逻辑说明:先归一化两个输入向量,保证后续点积的结果在数值上稳定;然后直接代入反射公式。注意这里要求法向量方向必须和入射方向“面对面”——如果 n 取反了,反射方向会整体翻转 180 度,这是最常见的低级错误。参数上不需要额外调参,但调用前必须确认 d_in 的定义是从入射点指向光源还是从光源指向入射点,两种约定直接差一个负号,我一般约定 d_in 从光源指向反射点,这样和射线追踪的习惯一致。
2.2 反射点求解转成标量极小化
知道反射方向还不够,题目通常给的是“光源位置、接收点位置、反射面方程”,要你反推反射点在哪。直接解方程需要把反射定律和曲面方程联立,多数情况下是非线性方程组,没有解析解。最常见做法是把问题转成单变量(或低维)极小化:在反射面上参数化一个候选点 P,光线从光源到 P,再从 P 到接收点,真实路径满足反射定律,同时等价于光程取极小——费马原理在这里就是救命稻草。
目标函数用光程或时间。如果介质均匀,光程就是距离之和:
[ F(P) = |\mathbf{P} - \mathbf{S}| + |\mathbf{T} - \mathbf{P}| ]
约束是 P 落在反射面上。如果反射面是解析曲面(比如平面 (z = ax + by + c) 或球面),可以直接消元降维;如果是散点插值出来的面,就需要把曲面参数化也作为优化的一部分。
from scipy.optimize import minimize def objective_opt(vars, S, T, surface_func): # vars: 曲面参数坐标(比如平面时是 x, y) P = surface_func(vars) # 由参数计算出空间点 d1 = np.linalg.norm(P - S) d2 = np.linalg.norm(T - P) return d1 + d2 # 平面反射面 z = 0.5*x + 0.2*y + 1.0 示例 def plane(vars): x, y = vars z = 0.5*x + 0.2*y + 1.0 return np.array([x, y, z]) S = np.array([2.0, 3.0, 5.0]) # 光源 T = np.array([4.0, 1.0, 2.0]) # 接收点 res = minimize(objective_opt, [0.0, 0.0], args=(S, T, plane), method='BFGS') P_reflect = plane(res.x) print("反射点:", P_reflect)这段代码把反射点问题变成了一个无约束优化——因为平面用 x, y 参数化后,z 自动满足方程,约束已经被“嵌入”参数化里了。逻辑要点:目标函数里没有直接写反射定律,但极小化光程等价于满足反射定律,这在物理上是严谨的;数值上 BFGS 收敛速度快,不需要求导信息。参数说明里,初始值 [0.0, 0.0] 比较关键,如果光源和接收点在 x 方向投影差很大,建议把初值设为光源和接收点投影的中点,否则可能收敛到局部极小——平面还好,曲面就容易出问题。
3. 完整可运行代码:从数据到答案的整条链路
3.1 随机算例生成与验证手段
实战里不能只跑一个案例就交差。题目一般给的是离散数据,你自己要生成可验证的算例来测试代码正确性。做法是:先随便定一个反射面,然后人为选一个“真实反射点”,从那里反推光源和接收点的位置关系,再拿程序去解,看能不能找回这个已知点。这比对着官方数据检查可靠得多。
import numpy as np from scipy.optimize import minimize def generate_case(seed=42): rng = np.random.default_rng(seed) # 随机平面: z = a*x + b*y + c a, b, c = rng.uniform(-1, 1, 3) def surface(vars): x, y = vars return np.array([x, y, a*x + b*y + c]) # 在地面上方随机取光源与接收点 S = np.array([rng.uniform(-3, 3), rng.uniform(-3, 3), rng.uniform(2, 6)]) T = np.array([rng.uniform(-3, 3), rng.uniform(-3, 3), rng.uniform(2, 6)]) return surface, S, T, (a, b, c)逻辑说明:随机生成平面系数 a, b, c 和光源/接收点坐标,用来检验反射点求解算法。核心思路是构造“可复现算例”,同一颗种子每次跑结果一致,避免“我这儿能跑你怎么跑不了”的玄学问题。参数说明:均匀分布采样范围就是几何场景的范围,如果你想测极端角度,把 S 和 T 的位置往外拉(比如 x 到 ±10),优化初值也要相应调整。
验证反射点的正确性用两个判据:一是目标函数值是否等于直接“光路”距离,二是入射角与反射角是否相等。第二点可以用矢量验证:
def validate_reflection(S, T, P, surface_normal): # 入射方向(从光源指向反射点) d_in = P - S d_in = d_in / np.linalg.norm(d_in) # 出射方向(从反射点指向接收点) d_out = T - P d_out = d_out / np.linalg.norm(d_out) # 法向量 n = surface_normal(P) n = n / np.linalg.norm(n) # 反射公式检查 d_reflected = d_in - 2 * np.dot(d_in, n) * n err = np.linalg.norm(d_reflected - d_out) return err # 接近 0 说明满足反射定律正常情况下 err 应该小于 1e-6。如果你跑出来到 1e-3 级别,说明优化精度不足,要把 minimize 的 tol 参数调小到 1e-10,或者换更高精度的求解器。这块是很多人忽略的“你算出来的点其实不是真反射点”的血泪来源。
3.2 曲面反射面的处理:参数化与初值策略
题目如果给了非平面反射面(比如抛物面、样条曲面),上面那套平面参数化就不能直接用了。常见做法是用“曲面自然坐标”参数化:用两个方向上的弧长或投影坐标。以抛物面 (z = x^2 + y^2) 为例:
def paraboloid(vars): x, y = vars z = x**2 + y**2 return np.array([x, y, z]) def solve_paraboloid(S, T): # 初值取光源与接收点在 xy 平面投影的中点 x0 = (S[0] + T[0]) / 2 y0 = (S[1] + T[1]) / 2 res = minimize(lambda v: np.linalg.norm(paraboloid(v) - S) + np.linalg.norm(T - paraboloid(v)), [x0, y0], method='BFGS', options={'gtol': 1e-8}) return paraboloid(res.x)逻辑说明:这里目标函数直接把 S 到曲面点和曲面点到 T 的两段距离相加,没有直接依赖反射定律表达式——费马原理保证解满足反射条件。曲面是凸的时候,目标函数的极小点唯一,BFGS 基本没有悬念;非凸曲面才是麻烦所在。参数说明:gtol 控制梯度收敛阈值,默认 1e-5 常常导致反射角验证误差偏大;建议设到 1e-8 甚至 1e-10,代价只是多迭代几十步,毫秒级耗时而已。
遇到非凸曲面,多点启动策略是必须的:在参数域里均匀撒 5~10 个初值,各自做一次本地优化,最后取目标函数值最小的一个作为反射点。以我的经验,这不是可选项,是必做项。
def multi_start_solve(S, T, surface_func, bounds, n_starts=8): best_res = None for i in range(n_starts): x0 = np.random.uniform(bounds[0], bounds[1]) y0 = np.random.uniform(bounds[2], bounds[3]) res = minimize(lambda v: np.linalg.norm(surface_func(v) - S) + np.linalg.norm(T - surface_func(v)), [x0, y0], method='BFGS') if best_res is None or res.fun < best_res.fun: best_res = res return surface_func(best_res.x)初值分布范围 bounds 怎么定?我一般的准则:比反射面在 xy 方向投影的直径略大 20%。太小会漏真实解,太大浪费计算时间。随机撒点的随机数种子建议固定,保证竞赛提交版本和调试版本完全一致——这个细节每年都在坑人。
4. 完整论文的组织:评审第一眼看的三个位置
4.1 问题重述与模型假设怎么写才不被质疑
竞赛论文和课程论文完全不是一回事。华中杯的评审一天要看几十篇,第一眼看摘要,第二眼看图表,第三眼看模型假设。很多人模型假设随便写“忽略光的波动性”“假设反射面光滑”,这不够,要写能让后续求解逻辑自洽的硬假设。
我常用的三条假设是:
- 反射面为理想镜面,满足几何光学反射定律,不考虑漫反射分量;
- 光线在均匀介质中沿直线传播,折射率恒定,光程与几何路程成正比;
- 反射面几何形状由题目给定数据精确确定,插值误差在可接受范围内(给出具体 RMSE 值)。
这三条各有作用:第一条把物理问题变成几何问题,第二条把费马原理简化为距离极小化,第三条直接为数值计算提供合法性——如果你用散点插值构造反射面,那插值误差本身必须写清楚,否则评审会追问你“面都不是真的,算出的点可信吗”。
问题重述部分,我建议控制在 300 字以内,核心是“用自己的语言压缩题目需求,列出输入数据和输出要求”。不是照抄题目原文,而是让评审一眼看出你读懂了题。
4.2 模型建立与求解的表述结构:公式+伪代码+图表
这一部分不能干巴巴贴代码,要按“物理模型 -> 数学模型 -> 数值格式 -> 验证结果”四段式走。
先写物理图像:光从光源出发,经反射面到达接收点;根据费马原理,实际光路是光程取极小的那条。再写数学模型:定义参数化曲面 (\mathbf{P}(u,v)),目标函数是两段距离之和,约束是 (u,v) 落在定义域内。然后写数值格式:用 BFGS 拟牛顿法求解,多点启动规避局部极小。最后写验证:与已知解析解(平面镜)对比,入射角等于反射角,误差量级 1e-8。
一个小技巧:论文里放一张反射路径的三维图,再加一张“迭代收敛曲线”(每轮优化的目标函数值随迭代次数的变化),评审对这两张图的印象分极高。收敛曲线同时也能证明你的初值策略有效——多条起点收敛到同一终值,说明解是稳定的。
图一定要用 matplotlib 或 MATLAB 画成矢量格式导出 PDF 嵌入论文,不能截图贴位图,否则一放大就糊。这也是“从细节看态度”。
5. 避坑与常见问题:跑不出正确结果时的排查清单
5.1 坑一:法向量方向反了,反射角差 180 度
现象:反射点坐标看起来在合理区域,但一验证反射定律,误差巨大;画出路径发现光线从反射点“弹回到光源那一侧”。
原因:模型假设里规定法向量指向入射空间,但实际数值计算时,曲面函数返回的法向量可能指向另一侧。平面情况下,z = ax + by + c 的法向量本身有两个方向(正 z 或负 z),取决于你取的叉积方向。
解决:代码里统一用入射方向和法向量做点积判断,如果点积为正就翻转法向量——保证法向量和入射方向“面对面”。
def safe_normal(P, S, normal_func): n = normal_func(P) d = S - P # S 是光源 if np.dot(n, d) > 0: n = -n return n这个修复十行以内,但能救回半天调试时间。如果题目给的是网格曲面(mesh),法向量从三角面片叉积得到时方向更混乱,务必加这一段兜底。
5.2 坑二:优化不收敛或收敛到错误反射点
现象:目标函数值在多次运行之间不稳定;或者不同初值跑出的反射点坐标差很远,且各自验证反射定律都能过但光程不一样。
原因:典型的非凸曲面多解问题。凹反射面上会有多个满足反射定律的驻点,其中只有一个是最短路(全局极小),其他是局部极小或鞍点。
解决:多点启动是标准手段,另外可以加一个筛选条件——验证反射定律后,还要验证“光线是否被反射面遮挡”。具体来说,检查光源到反射点之间的线段是否与反射面相交(除了终点),如果相交说明这条光路被曲面自身挡住了,物理上不成立,直接删掉。
def is_occluded(S, P, surface_func, n_samples=20): # 在 S 到 P 之间采样,检查是否有其他曲面点 for i in range(1, n_samples): t = i / n_samples Q = S + t * (P - S) # 判断 Q 是否在曲面上(近似) Q_proj = surface_func([Q[0], Q[1]]) if np.linalg.norm(Q - Q_proj) < 1e-6: return True return False这个检查必须做,否则论文里画出来的光路图可能直接穿过反射面,评审看到基本就没了。
5.3 坑三:数据预处理不当,单位不一致
现象:代码在本地运行正常,换一台电脑或换一组数据就发散,或者反射点坐标出现 1e10 量级的异常值。
原因:输入数据可能是经纬度加海拔、也可能是纯坐标加相对高度,单位不统一。比如反射面坐标用米、光源坐标用千米,距离计算时差 1000 倍,BFGS 的梯度估计直接爆掉。
解决:最前面加一个标准化步骤——把所有坐标减去质心后除以尺度因子(一般取所有坐标的标准差),让数值量级落在 1 附近。优化完成后再反变换回原单位。
def normalize(points): center = np.mean(points, axis=0) scale = np.std(points, axis=0) return (points - center) / scale, center, scale def denormalize(points_norm, center, scale): return points_norm * scale + center标准化不只解决优化稳定性,还能让 tol 参数设置更合理——没标准化时 tol=1e-8 可能严格到无法满足,标准化后同样阈值则非常轻松。这是我在 U 形曲面案例里踩出来的最深刻的坑。
5.4 坑四:验证代码跑得慢,论文里精度指标虚高
现象:论文里写“误差达 1e-10”,但实际用蒙特卡洛随机生成 500 个反射点验证时,平均误差在 1e-4 量级;回头看发现只是某个特定案例算得好。
原因:单一算例优化容易“过拟合”到某个凹面局部区域;样本多了才暴露初值策略覆盖率不足。
解决:批量验证脚本一定要求跑完 200 组随机算例并统计平均误差、最大误差、失败率。失败率超过 5% 就说明初值策略或容差设置有系统性问题,不要试图用“论文只写最好的一组”来掩盖。
errors = [] for i in range(200): surface, S, T, _ = generate_case(seed=i) P_est = solve_reflect(surface, S, T) err = validate_reflection(S, T, P_est, surface_normal) errors.append(err) print(f"平均误差: {np.mean(errors):.2e}, 最大误差: {np.max(errors):.2e}")顺便说一句,如果多组随机算例里最大误差高了两个数量级,往往是某一组数据触发了法向量方向翻转的 bug,而不是优化问题本身——先查 safe_normal,再查初值策略。
6. 一次图表生成的批量自动化:从数据直接出论文素材
到这里大多数队伍会手动跑少数算例,选好看的图贴进论文。但这样做有两个问题:一是不够系统,评审问到“其他算例呢”就尴尬;二是图与图之间坐标范围不统一,整篇论文看起来像拼凑出来的。我习惯的做法是写一个批量脚本,自动生成所有必要图表并按统一风格排版输出,这一步做完直接进 Overleaf 排版,省半天时间。
核心是一套统一的绘图函数,把反射面、光路、法向量画在一张图里。我通常用 matplotlib 的 3D 投影,配合参数化网格采样画曲面:
import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D def plot_reflection(surface_func, S, T, P, normal_at_P, save_path): fig = plt.figure(figsize=(8, 6)) ax = fig.add_subplot(111, projection='3d') # 画反射面:参数域网格采样 u = np.linspace(-3, 3, 50) v = np.linspace(-3, 3, 50) U, V = np.meshgrid(u, v) Z = np.array([[surface_func([u_i, v_i])[2] for u_i in u] for v_i in v]) ax.plot_surface(U, V, Z, alpha=0.5, color='lightblue') # 画光路 S -> P -> T ax.plot([S[0], P[0]], [S[1], P[1]], [S[2], P[2]], 'r-o', linewidth=2) ax.plot([P[0], T[0]], [P[1], T[1]], [P[2], T[2]], 'r-o', linewidth=2) # 画法向量 ax.quiver(P[0], P[1], P[2], normal_at_P[0], normal_at_P[1], normal_at_P[2], color='green', length=0.5, normalize=True) # 标注 ax.scatter(*S, color='orange', s=60, label='光源') ax.scatter(*T, color='purple', s=60, label='接收点') ax.scatter(*P, color='red', s=80, label='反射点') ax.legend() plt.savefig(save_path, dpi=300, bbox_inches='tight') plt.close()参数说明:dpi=300 是竞赛论文印刷的最低标准,低于 200 会被审稿人看出来模糊;bbox_inches='tight' 自动裁掉空白边距,避免手工调图;alpha=0.5 控制曲面透明度,太透明看不出形状、太实会盖住光路。
配合批量脚本,一次运行生成 20 张算例图、一张收敛曲线图、一张误差统计柱状图,论文素材一次性齐活。这也逼着代码管线保持稳定——不会有“我手动调过某个算例所以那个图特别漂亮”的不可复现问题。
这个批量自动化习惯,算是我自己从第二次参赛之后就离不开的流程了。竞赛里面“能跑”只是及格线,“10 分钟内复现全部图表数据”才是真正决定你在赛场上能用多少轮迭代打磨论文的底气。希望你拿到代码后先跑通我的示例,再上手换自己的数据——这比上来就魔改代码省时间得多,希望帮到你。
本文还有配套的精品资源,点击获取