判断点在多边形内(射线法)是我几乎每天都在用的算法。在地图引擎、游戏碰撞检测、图像标注工具里,它都是最基础的那块拼图。这算法听起来高深,其实核心就一句话:从目标点朝任意方向射出一条射线,数一数它和多边形边界相交了几次,交点个数是奇数就在内部,偶数就在外部。就这么朴素,但它把“几何包含关系”变成了一次简单的计数,几十年来都没被淘汰。
这篇文章适合刚接触计算几何的开发者,也适合被顶点相交、浮点精度折磨过的老手。我会从原理讲到完整实现,再重点拆解那些教科书里不会明说的边界条件——也就是真正决定算法能不能用的地方。如果你只需要一个能跑的方案,可以直接跳到第2节抄代码;如果你想搞清楚为什么这么写,甚至连射线为什么不能随便乱选方向这种细节都想弄明白,那就从头往下看。
1. 射线法的核心原理与算法步骤
1.1 从生活直觉到数学判定
先忘掉代码,想象你在一个形状不规则的地块旁边,手里拿一根激光笔,站在某个不确定是不是在地块内部的点位上,朝正东方向发射一束激光。激光穿出地块边界的时候,你会记录一次“穿越”。如果最终穿越次数是奇数,说明激光是从地块内部出发的;如果是偶数,说明激光是从外部射进来的。这个直觉就是射线法的全部。
为什么奇数/偶数能判定内外?因为多边形边界是闭合的,一条直线穿过闭合区域时,必然是一会儿进一会儿出。从外部出发,进入一次、出去一次,成对出现,最后一定是偶数;从内部出发,第一次就是出界,最后一定是奇数。这不需要任何复杂的拓扑学,画个图就能验证。
不过这里有个容易忽略的点:射线方向的选择。理论上任意方向都可以,但为了把“线段求交”的数学公式尽量简化,几乎所有实现都选水平向右的射线。原因很实际:水平射线可以固定某个坐标(比如y不变),求交点时只需要算x坐标与线段端点x坐标的关系,避免了对任意直线方程做繁琐的联立求解。这个取舍让每次相交测试的运算量降到几个比较和一次除法,在数百万次调用场景下性能差异巨大。
1.2 标准实现流程与关键公式
射线法的标准流程分四步:
第一,遍历多边形的每条边,每条边由两个连续顶点(xi, yi)和(xj, yj)定义,最后一条边记得从末顶点绕回首顶点。
第二,判断这条边是否“跨越”了射线所在的水平线。这里不能用简单的yi > y或者yj > y,因为顶点刚好落在射线高度时会产生重复计数。业界通用的做法是“上闭下开”规则:只有当 (yi > y) != (yj > y) 时,才认为边的两个端点位于射线水平线的两侧。这样顶点即使正好在y坐标上,也只被计数一次,不会算重。
第三,如果边跨越水平线,就计算射线与线段的交点的x坐标。根据相似三角形,公式是:
x_intersect = xi + (y - yi) * (xj - xi) / (yj - yi)第四,比较x_intersect和目标点的x坐标。如果大于x,说明交点在目标点右侧,计数加一;如果等于x,说明目标点就在这条边上;如果小于x,忽略。
遍历完所有边之后,交点数为奇数则在多边形内,偶数则在多边形外。整段逻辑连一个三角函数都用不到,全是四则运算和比较,这正是它成为基础算法的原因。
1.3 为什么用射线法而不是其他方法
接触过计算几何的人可能还会想到其他方案:角度和法、叉积法、网格剖分法。拿它们和射线法对比,你才能理解为什么射线法在工程里最常用。
角度和法需要把目标点和多边形每个顶点连起来,计算所有夹角之和。若和等于360度则在内部,等于0度则在外部。这个方法数学上是严谨的,但每次要调用atan2计算角度,既有三角函数开销,又容易被浮点误差影响,边界情况极难处理。
叉积法通常只适用于凸多边形。判断一个点是否在所有边的同一侧,效率很高,但遇到凹多边形直接失效。实际业务场景里的地块、行政区划、UI热区,哪有那么多凸多边形。
网格剖分法比如扫射线把多边形切成梯形或三角形,需要预处理,一次判断速度极快,但构建索引的代价不是所有场景都能接受。射线法不需要任何预处理,存储只依赖多边形本身的顶点列表,一个点算一次也是这个复杂度,一万个点算一万次也依然是O(n),实用性拉满。
所以技术选型的逻辑很清晰:射线法是通用性、简单性、可维护性之间的最佳平衡点。当你只需要回答“点在不在里面”时,没必要引入更重的数据结构。
2. 手把手实现:从伪代码到可运行代码
2.1 数据结构与输入约定
动手写代码之前,先把输入约定想清楚,否则后面全是坑。我习惯用两个数组分别存x坐标和y坐标,也可以用点对象列表。这里统一用点对象列表,每个点至少包含x和y两个属性。
多边形约定为“环”,也就是按顺序排列的顶点序列,最后一个顶点不需要和第一个顶点重复。比如一个矩形可以表示为[(0, 0), (4, 0), (4, 3), (0, 3)],而不是把(0,0)写在末尾再写一次。如果你拿到的数据是闭合环(首尾重复),实现里要先判断并去掉最后那个重复点,或者在遍历边时跳过首尾相同的边。
另一个约定是坐标系。射线法本身不关心坐标是屏幕像素坐标还是经纬度坐标,只要所有数值处于同一坐标系,乘法除法不会溢出就行。但经纬度坐标在跨180度经线时会出幺蛾子,这个我会在第4节单独讲。
2.2 基础实现(Python版本)
我尽量写一个没有花哨语法、不依赖外部库的Python实现,方便你直接搬到C++、JavaScript、Go等任何语言。第一个版本先实现“纯射线计数”,暂不处理点在边上的情况:
def point_in_polygon_ray(point, polygon): x, y = point n = len(polygon) inside = False j = n - 1 for i in range(n): xi, yi = polygon[i] xj, yj = polygon[j] # 判断边是否跨越射线所在的水平线:上闭下开 if (yi > y) != (yj > y): # 计算边与水平线交点x坐标 x_intersect = (xj - xi) * (y - yi) / (yj - yi) + xi # 只统计交点位于目标点右侧的情况 if x_intersect > x: inside = not inside j = i return inside这里用的是“每次交点就翻转inside”的写法,效果等同于数奇偶。注意我在循环外维护了一个j,让它指向前一个顶点,这样不需要额外处理闭合回绕,也避免了重复创建对象。
这段代码已经能处理绝大多数常规多边形。但如果目标点恰好落在边上,它会根据边的方向随机返回True或False,这是不可接受的。完整版本必须把“点在边上”的情况显式判断出来。
2.3 完整实现与边界处理
完整版在每一条边求交点之后,先判断点是否在边上,再判断是否要翻转inside。判断点在边上的方法有很多,我推荐用“跨立实验+叉积为零”的方式,因为已经有了交点x坐标,可以直接比较目标点是否在线段范围内。
下面这段代码我实际用在多个项目里,处理了顶点相交和边重合问题:
def point_in_polygon(point, polygon): x, y = point n = len(polygon) inside = False j = n - 1 for i in range(n): xi, yi = polygon[i] xj, yj = polygon[j] # 快速检测点是否在这条边的包围盒内 if min(xi, xj) <= x <= max(xi, xj) and min(yi, yj) <= y <= max(yi, yj): # 叉积判断是否共线 cross = (xj - xi) * (y - yi) - (yj - yi) * (x - xi) if abs(cross) < 1e-9: return True # 点在边上 # 射线求交:上闭下开规则避免顶点重复计数 if (yi > y) != (yj > y): x_intersect = (xj - xi) * (y - yi) / (yj - yi) + xi if x_intersect > x: inside = not inside j = i return inside关键点全在这个函数里:
首先是叉积判断点是否在边上。叉积为零表示目标点和线段端点共线,再配合包围盒判断保证目标点的坐标确实落在线段范围内,而不是在延长线上。严格说这个快速包围盒条件已经把大多数不可能情况过滤掉了,所以这段代码在日常使用中性能也不会太差。
其次是“上闭下开”规则。当顶点yi和yj都不大于y时,条件为False,这条边不会参与计数。这样当射线恰好穿过一个顶点时,只有一侧的边会统计这个顶点,不会出现一个顶点被两条邻边各计一次导致奇偶翻转被抵消的情况。这就是射线法最容易踩的坑,不少经典教材里的旧实现都栽在这里。
最后是浮点精度。叉积判断用了1e-9作为阈值,这个值不是随便拍的。如果你的坐标是整数像素坐标,可以放宽到1e-6;如果是经纬度坐标,1e-9已经比较严格,但还要结合坐标量级调整。后面我专门写一节讲精度问题。
2.4 一个可直接验证的示例
拿一个凹多边形做测试最能说明问题。比如多边形顶点为[(0,0), (4,0), (4,4), (2,2), (0,4)],这个形状像个倒置的勺子,凹口在右边。用上面的函数测试几个点:
- 点(1,1):肉眼可见在多边形内部,函数应该返回True。
- 点(3,1):虽然在(4,0)到(4,4)右边线的左边,但它在凹口外部区域,实际是False。
- 点(2,2):正好落在(4,4)到(2,2)这条边的端点上,应该返回True。
我实测一遍,手算一下点(3,1)的求交过程:从(2,2)到(0,4)这条边,水平线y=1并没有被跨越,因为两个点的y都大于1,条件为False;从(4,0)到(2,2)这条边,y=1在0和2之间,跨越条件成立,计算x_intersect = (2-4)*(1-0)/(2-0)+4 = 3,恰好等于目标点x=3。这里需要注意,我的代码里判断是x_intersect > x,严格大于,所以这个交点没有计数。最终交点数为偶数,结果False。这个设计是为了配合点在边上时已经提前返回True,避免边界归属混乱。如果你想用>=,就把点在边上的判断放在前面,结果一致。
3. 边界条件与坑位排查
3.1 点在边上、顶点相交怎么办
第2节的代码已经处理了这两种情况,但我想再啰嗦几句为什么必须处理,以及处理不当会发生什么。
点在边上时,理论上“内部”和“外部”的边界归属取决于你的应用。地图选点工具通常会算作命中内部;游戏碰撞里可能算作碰撞;GIS里的拓扑校验可能要求单独返回“在边界上”的状态。如果你只需要布尔值,那么返回True是最符合直觉的。
顶点相交更危险。假设一个不带“上闭下开”的旧版本算法,从点向右发射射线恰好经过多边形某个顶点,而该顶点两侧的两条边都跨越了同一水平线,那么这个顶点会被计数两次,两个交点一抵消,奇偶性又恢复原状,结果碰巧正确;但另一些顶点构型下,两条边可能一条跨越一条不跨越,计数一次,结果就是错误的。这个错误不是理论威胁,而是实际在多边形密集的网格数据中反复出现,表现为某些点反复横跳,一会儿在内一会儿在外。
3.2 射线穿越顶点和与边重合的经典Bug
与边重合是射线法另一个臭名昭著的坑。当多边形有一条边恰好和射线所在的水平线重叠时,比如y坐标相同的水平边,如果只按“跨越”条件处理,这条水平边根本不会被计入,因为yi > y和yj > y同为False。这是正确的,不需要额外处理。
但问题出在水平边的端点与另一条非水平边的端点共用顶点时。比如水平边从(2,2)到(4,2),另一条斜边从(4,2)到(5,3),射线从点(1,2)向右射。如果不做任何处理,当遍历到水平边时,两个y都等于2,不算跨越;遍历到斜边时,(2 > 2) != (3 > 2),跨越条件成立,会计算一个交点。顶点(4,2)仍然只被计数一次,结果是对的。
真正容易翻车的是另一种情况:多边形存在两条边共享同一顶点,并且该顶点在射线高度上,而这两条边分别在射线的两侧。旧版射线法经常会把这个顶点同时算给两条边,或者都不算给两条边。用“上闭下开”规则后,只有当一条边的某个端点严格大于y,另一个端点小于等于y时才跨越。这样一来顶点只会被计入一次,因为从大于y到小于等于y的那一侧边被计入,从小于等于y到大于y那一侧边不被计入。这个规则的记忆方法很简单:把所有顶点当作一个“半边在下、半边在上”的开区间,永远只统计从上方穿到下方的那一侧。
3.3 奇偶规则 vs 非零环绕数
射线法常见的有两套判断准则:奇偶规则(Even-Odd Rule)和非零环绕数(Nonzero Winding Number)。它们对简单多边形的判定结果一致,但对自相交多边形会产生差异。
奇偶规则就是我上面讲的,任何一条边穿过射线就翻转一次。自相交多边形就像两笔画出来的回字形中间交叉,某些区域在奇偶规则下会被判为外部,但在环绕数规则下被判为内部。原因是环绕数会考虑边的方向,按逆时针方向累计角度变化,如果累计值非零则判定为内部。
不少商业图形库默认采用非零环绕数,因为它能正确处理带洞或者重叠路径的图形。但非零环绕数需要计算射线与边的带符号交点,每条边要区分向上还是向下穿越,逻辑比奇偶规则复杂一些。我的建议是:如果你的数据来源是GIS或游戏碰撞体,基本都是简单多边形,用奇偶规则已经足够;如果你在解析SVG路径或字体轮廓,那些数据里可能有自相交或重叠的腱规则,就必须用非零环绕数。
def point_in_polygon_winding(point, polygon): x, y = point n = len(polygon) winding = 0 j = n - 1 for i in range(n): xi, yi = polygon[i] xj, yj = polygon[j] if yi <= y: if yj > y and (xj - xi) * (y - yi) > (x - xi) * (yj - yi): winding += 1 else: if yj <= y and (xj - xi) * (y - yi) < (x - xi) * (yj - yi): winding -= 1 j = i return winding != 0这段代码用叉积判断交点是否在点右侧,同时根据边的方向决定交点是正向还是负向。绕数不为零即内部。它比奇偶规则多了一点运算,但换来了对路径缠绕的宽容。
3.4 多边形方向、坐标精度、浮点误差处理
多边形顶点顺序在射线法中其实不重要,奇偶规则对顺时针和逆时针都一视同仁。但如果你在同一个系统里既要判断点又要计算面积,最好统一要求顶点按逆时针顺序存储,这样面积、法向量、填充规则都能保持一致。
浮点误差是个大话题。射线法的运算量小,但除法容易放大误差。比如顶点坐标达到上百万的经纬度值(保留6位小数),在计算x_intersect时,(y-yi)/(yj-yi)这个比例可能非常接近0或1,导致乘法的舍入误差被放大。解决思路有三层:
第一层,优先用叉积代替直接求交点。某些判断场景其实不需要算出具体的x_intersect,只需要知道交点与目标点的左右关系,可以直接用叉积符号判断。这样既减少了一次除法,也减少了误差。
第二层,对坐标做中心平移。把多边形所有顶点都减去一个固定基准点(比如多边形的包围盒中心),将坐标数量级缩放到0~100之间,再进行射线法。这个技巧在地图跨大面积区域时特别有效。
第三层,设置合理的epsilon。点在边上的叉积判断不要用==0,要根据坐标量级设阈值。整数坐标可以用1e-6;浮点坐标最好用1e-9再乘以坐标范围。千万别搞什么“一刀切”的全局精度,不同数据源误差天差地别。
4. 性能优化与工程应用细节
4.1 包围盒快速排除
射线法虽然只有O(n)复杂度,但n很大时,比如几万个顶点的地块边界,每个点都遍历一遍还是有点心痛。最常见的优化是先做多边形包围盒:遍历顶点时顺便求出最小x、最小y、最大x、最大y,存下来。判断新点时,如果点不在包围盒内,直接返回False,不需要进入射线求交循环。
def build_bbox(polygon): xs = [p[0] for p in polygon] ys = [p[1] for p in polygon] return (min(xs), min(ys), max(xs), max(ys)) def point_in_polygon_fast(point, polygon, bbox): x, y = point min_x, min_y, max_x, max_y = bbox if x < min_x or x > max_x or y < min_y or y > max_y: return False return point_in_polygon(point, polygon)这个优化的收益在凹多边形场景下特别明显。很多点根本不在包围盒内,一个O(1)判断就扔掉了。就算点在包围盒内,也只是进入原来的逻辑,没有额外负担。
4.2 数据量大的场景怎么加速
如果多边形数量很多而不是单个多边形顶点很多,比如地图上有几万个行政区,每次请求要判断点落在哪个区域,那单靠射线法一个个试肯定不够。常见的加速方案是网格索引或空间索引。
网格索引的做法是把整个范围切分成粗粒度格子,每个格子记录可能包含这个点的多边形列表。请求到达时先定位到格子,再对格子里的多边形做射线法。这个方案实现简单,适合多边形分布比较均匀的场景。
更通用的是R树或四叉树。很多内存数据库和GIS库都内置了这类索引。我自己的经验是:数据量小于一万个多边形时,暴力遍历加包围盒就足够;超过十万个格时,务必引入索引,否则单次请求就会超过50毫秒,用户体验明显变差。
射线法本身也还能再做一点微优化:把多边形存储为两个数组(x数组和y数组)而不是点对象数组,这样在循环里能减少属性访问开销;提前把首顶点复制到尾后,用“for i in range(n): j = (i + 1) % n”的方式访问,不过取模操作有额外开销,实际要用性能分析说话,别盲信。
4.3 在GIS、游戏碰撞、图像处理中的应用细节
GIS里最常见的是经纬度坐标。经纬度不是平面坐标,但射线法在判断包含关系时天然对“平面坐标”有效。原因是一个地理区域的边界通常不会跨越反经线(即经度从-180到180跳变),如果跨越了,就需要先做投影或对经度做平移处理。另一种处理方式是把-180到180映射到0到360,让边界连续。我在一个涉及跨越太平洋的航线多边形上就踩过这个坑,后来统一做经度归一化才修复。
游戏开发中射线法多用于鼠标点击拾取(Picking)。屏幕上的UI多边形往往是凸多边形,用叉积法更高效。但如果涉及不规则碰撞体或地图上的区域判定,射线法还是首选。需要注意游戏引擎的坐标系Y轴朝下,而我们日常数学习惯Y轴朝上,奇偶规则不关心Y轴方向,所以可以无脑适用,这是射线法的又一个好处。
图像处理里的应用更微妙。比如在标注工具里填充一个多边形区域,需要逐像素判断是否在多边形内部。如果图片是1920x1080,直接对每个像素跑射线法,性能会非常难看。正确的做法是先用扫描线把多边形内部的行区间算出来,然后逐行填充,一次像素判断退化为区间判断。射线法是扫描线算法的基础,明白射线法之后,你能更好地理解图像矢量填充的原理。
4.4 与GeoJSON/WKT等格式结合的注意点
GeoJSON的Polygon类型可能是“外部环+内部洞”的结构。一个带洞的多边形实际上由多个环组成:外环逆时针,内环顺时针。判断点是否在几何体内部时,先判断点是否在外环内,同时要确保不在任何一个内洞内。用射线法分别判断每个环,然后“外环结果 AND (OR 每个洞取反)”。
WKT格式里的POLYGON((外环),(洞1),(洞2))也是类似。很多开发者只判断了外环,导致洞区域也被错误判定为内部。遇到这种情况,我建议封装一个函数,接收外环和洞列表:
def point_in_polygon_with_holes(point, exterior, holes): if not point_in_polygon(point, exterior): return False for hole in holes: if point_in_polygon(point, hole): return False return True注意这里的hole方向是否被翻转不重要,因为射线法对方向无感,只要顶点顺序构成闭合环就行。还要注意GeoJSON的坐标系是经纬度,但坐标数值通常保留6位小数,用float64存储没有问题,但在3.4节提到的误差处理还是要做。
5. 常见问题与实战排查速查
5.1 典型问题1:射线经过顶点
现象:同一个点在不同次调用中结果不稳定;或者明显在内部的点被判为外部。
原因:射线恰好穿过多边形某个顶点,导致该顶点的两条邻边都被计数或者都被跳过,奇偶性翻转次数不对。
解决:严格采用“上闭下开”条件 (yi > y) != (yj > y)。这个条件确保当两个端点在y高度两侧时才计算交点,等于y的端点不会触发跨越。说白了就是把射线稍微“抬起来”0.000001个像素,避免刚好压在顶点上。在代码里不真的抬高,而是用开闭区间模拟。
5.2 典型问题2:点在边界上
现象:点在多边形边上,但结果有时返回True,有时返回False,无法稳定表达“边界命中”。
原因:纯射线计数算法没有显式检测点在边上的情况,边的方向会影响最终计数。
解决:在每次遍历边时加入叉积判断,如果点与线段共线且点的坐标在线段包围盒内,直接返回True。注意返回True前要确认这不是“假共线”,例如浮点误差导致的叉积极小值。设置合适的epsilon并检查包围盒条件,缺一不可。
5.3 典型问题3:退化多边形/自相交多边形
现象:多边形有两个连续重复顶点、有零长度边、或者边与边交叉。
原因:零长度边会导致除法分母为零;自相交会导致奇偶规则和环绕数规则结果不一致。
解决:数据预处理阶段先去除重复顶点。遇到分母为零时,直接跳过该边。对自相交多边形,如果业务允许,优先换成非零环绕数算法;如果业务不允许(比如GIS强约束简单多边形),那就在数据校验阶段拒绝这类多边形。
5.4 经验表
我把常见场景对应的方案整理成一张表,方便你排查时直接对号入座:
| 症状 | 原因 | 解决方案 |
|---|---|---|
| 结果时对时错 | 射线经过顶点 | 使用上闭下开条件 |
| 点在边上误判 | 缺边界检测 | 叉积+包围盒判断 |
| 带洞区域被误判 | 只判断外环 | 分别判断内洞并对结果取反 |
| 坐标跨180经线 | 经度不连续 | 经度偏移到连续坐标系 |
| 大坐标下误差大 | 除法放大了舍入误差 | 中心平移 + epsilon调整 |
| 数据量大时太慢 | 每次都遍历全部顶点 | 包围盒快速排除 + 空间索引 |
5.5 一个真实场景的排查实录
有次我在做某跨平台地图系统时,发现某个行政区域的选中判定在边缘坐标上总是漂移。最初以为是坐标精度不够,把经纬度从小数点后6位加到8位也没用。后来打印出所有交点计算过程,才发现是因为多边形顶点是从数据库动态加载的,每次加载顺序不一致,导致某个顶点在射线正下方和射线正上方之间抖动,触发了顶点重复计数bug。换成“上闭下开”之后,问题立刻消失。这个经历告诉我,射线法虽然简单,但数据源的微小差异就能引爆边界问题,写代码时宁可把规则写得死板一点,也不要依赖“碰巧正确”。
6. 我在实际项目中的专属建议
最后分享几个我自己踩过坑之后总结的习惯。
第一个习惯是永远先写测试用例。不要只在纸上画一个四边形验证,要准备凸多边形、凹多边形、带洞多边形、自相交多边形、点在边上、顶点刚好与射线同高、坐标极大极小等各种案例。我用一组约五十个点的多边形做回归测试,每次改射线法实现都跑一遍,确保不会因为优化破坏边界行为。
第二个习惯是尽量别自己去实现有洞多边形的处理。如果项目允许引入外部几何库,直接用成熟的库更省心。但就算用库,也得明白库背后的射线法原理,否则你很难判断某个bug是数据问题还是算法问题。
第三个习惯是能用整数坐标就不用浮点坐标。像素级UI、网格数据、磁贴数据都可以用整数坐标,叉积运算完全不会出现误差问题。只有经纬度、物理单位数据才需要浮点,那时再启动epsilon策略。
射线法真正的价值不在于算法本身有多高明,而在于它把复杂几何问题降维成了一个稳定可靠、可测试、可解释的计数问题。你在一个项目里把这个基础打牢了,后面再碰到多边形面积、线段求交、点集凸包,都会顺手很多。希望这篇内容能让你少踩一些我当年踩过的坑。