你这是什么垃圾浏览器,这都不能显示(╯‵□′)╯︵┻━┻

地理信息(GIS)工具

Author Avatar
猪蹄宝宝 2023-07-15
  • 在其它设备中阅读本文章

近期有个需求是打卡功能,需要判断用户位置是否在指定打卡范围内。判断打卡范围有两种思路,一种是以目标点为中心画一个圆,判断点距离圆心的距离,小于圆的半径即可;另一种则是画出电子围栏(以多个有顺序的点构筑一个多边形)判断点是否在多边形内。

点之间的距离

Haversine

由于地球是椭球体,且定位所获得的经纬度是角度而不是传统意义上能表示距离的坐标,一般有两种思路,由于地球的各地半径差距不大,因此可以近似看作球体,以 Haversine 公式计算距离。注意美团的文章中的 haversine 公式有错误,本文中已修复。

参考: https://tech.meituan.com/2014/09/05/lucene-distance.html

public abstract class GeographicUtils {
    /**
	 * 地球半径.
	 */
    public static final double EARTH_RADIUS = 6378137.0;

	/**
	 * 计算两点之间的距离(Haversine公式).
	 *
	 * @param lng1 经度1
	 * @param lat1 纬度1
	 * @param lng2 经度2
	 * @param lat2 纬度2
	 * @return 距离(米)
	 */
	public static double distHaversineRAD(double lng1, double lat1, double lng2, double lat2) {
		double latDistance = Math.toRadians(lat2 - lat1);
		double lngDistance = Math.toRadians(lng2 - lng1);
		double sinLat = Math.sin(latDistance / 2);
		double sinLng = Math.sin(lngDistance / 2);
		double a = sinLat * sinLat +
				Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * sinLng * sinLng;
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
		return EARTH_RADIUS * c;
	}
}

简化:多项式拟合

由于这个算法中有大量三角函数的计算,且我们计算距离的范围一般不会超过一个省,因此可以使用多项式拟合,精度并不会下降很多。

public abstract class GeographicUtils {
	/**
	 * 地球半径.
	 */
	public static final double EARTH_RADIUS = 6378137.0;
	private static final double[] POLYNOMIAL_FITTING_RESULTS = trainPolyFit(3, 1000);

	/**
	 * 多项式曲线拟合结果.
	 *
	 * @param degree 拟合次数
	 * @param length 分段粒度,100以上即可获得较好的拟合效果
	 * @return 拟合结果
	 */
	public static double[] trainPolyFit(int degree, int length) {
		PolynomialCurveFitter polynomialCurveFitter = PolynomialCurveFitter.create(degree);
		double minLat = 10.0; // 中国大陆范围(不含南海诸岛)最低纬度
		double maxLat = 60.0; // 中国大陆范围最高纬度
		double intern = (maxLat - minLat) / (double) length;
		List<WeightedObservedPoint> weightedObservedPoints = new ArrayList<>();
		for (int i = 0; i < length; i++) {
			double x = minLat + (double) i * intern;
			WeightedObservedPoint weightedObservedPoint =
					new WeightedObservedPoint(1, x, StrictMath.cos(StrictMath.toRadians(x)));
			weightedObservedPoints.add(weightedObservedPoint);
		}
		return polynomialCurveFitter.fit(weightedObservedPoints);
	}

	/**
	 * 计算两点之间的距离.
	 *
	 * @param lng1 经度1
	 * @param lat1 纬度1
	 * @param lng2 经度2
	 * @param lat2 纬度2
	 * @return 距离(米)
	 */
	public static double distanceSimplifyMore(double lng1, double lat1, double lng2, double lat2) {
		// 1) 计算三个参数
		double dx = lng1 - lng2; // 经度差值
		double dy = lat1 - lat2; // 纬度差值
		double b = (lat1 + lat2) / 2.0; // 平均纬度
		// 2) 计算东西方向距离和南北方向距离(单位:米),东西距离采用三阶多项式
		double Lx = (POLYNOMIAL_FITTING_RESULTS[3] * b * b * b
				+ POLYNOMIAL_FITTING_RESULTS[2] * b * b
				+ POLYNOMIAL_FITTING_RESULTS[1] * b + POLYNOMIAL_FITTING_RESULTS[0])
				* Math.toRadians(dx) * EARTH_RADIUS; // 东西距离
		double Ly = EARTH_RADIUS * Math.toRadians(dy); // 南北距离
		// 3) 用平面的矩形对角距离公式计算总距离
		return Math.sqrt(Lx * Lx + Ly * Ly);
	}
}

椭球体计算:Vincenty

使用椭球体计算的 Vincenty 方法可以参考: https://www.movable-type.co.uk/scripts/latlong-vincenty.html

电子围栏

射线法

电子围栏的本质是将若干个点按顺序画出多边形,判断点是否在多边形内。这也有两种算法,一种是射线法,即将点向一个方向射出一条射线(一般是右边),判断与多边形相交的点数,为奇数则说明在多边形内。算法参考:http://alienryderflex.com/polygon/

public abstract class GeographicUtils {
	/**
	 * 判断点是否在多边形内部(射线法).
	 * <p>
	 * 该方法计算速度快,但是不精确,如果点在多边形边上,则其结果不固定.
	 *
	 * @param x       x坐标
	 * @param y       y坐标
	 * @param polygon 多边形顶点坐标
	 * @return true:在多边形内部; false:不在多边形内部
	 */
	public static boolean isPointInPolygonRayCasting(double x, double y, double[][] polygon) {
		int i;
		int j = polygon.length - 1;
		boolean oddNodes = false;
		for (i = 0; i < polygon.length; i++) {
			double[] polyI = polygon[i];
			double[] polyJ = polygon[j];
			double polyXi = polyI[0];
			double polyYi = polyI[1];
			double polyXj = polyJ[0];
			double polyYj = polyJ[1];
			if ((polyYi < y && polyYj >= y
					|| polyYj < y && polyYi >= y)
					&& (polyXi <= x || polyXj <= x)) {
				oddNodes ^= (polyXi + (y - polyYi) / (polyYj - polyYi) * (polyXj - polyXi) < x);
			}
			j = i;
		}
		return oddNodes;
	}
}

该算法很快但不精确,也不能判断点是否在多边形上,对于可能有负数的经纬度判断、围绕南北极点的电子围栏可能计算错误,需要处理数据,也不适合过于复杂的多边形判断。此时我们可以使用 jdk 的 Path2D 工具类实现

public abstract class GeographicUtils {
	/**
	 * 使用Path2D判断点是否在多边形内,该方法很精确,但是很慢.
	 *
	 * @param x              x
	 * @param y              y
	 * @param polygon        多边形顶点坐标数组
	 * @param containsBorder 是否包含边界
	 * @return 是否在多边形内
	 */
	public static boolean isPointInPolygonPath2D(double x, double y, double[][] polygon, boolean containsBorder) {
		int numberOfVertices = polygon.length;
		Path2D.Double path = new Path2D.Double();
		// 构建多边形路径
		path.moveTo(polygon[0][0], polygon[0][1]);
		for (int i = 1; i < numberOfVertices; i++) {
			path.lineTo(polygon[i][0], polygon[i][1]);
		}
		path.closePath();
		// 创建待判断的点对象
		Point2D.Double point = new Point2D.Double(x, y);
		// 判断点是否在多边形内部
		if (path.contains(point)) {
			return true;
		}
		if (containsBorder) {
			// 排除点在多边形边上的情况
			double distanceThreshold = 1e-6; // 设置一个距离阈值作为判定边上的依据
			for (int i = 0; i < numberOfVertices; i++) {
				double[] polyI = polygon[i];
				double[] polyJ = polygon[(i + 1) % numberOfVertices];
				Line2D.Double edge = new Line2D.Double(polyI[0], polyI[1], polyJ[0], polyJ[1]);
				double distance = edge.ptSegDist(x, y);
				if (distance <= distanceThreshold) {
					return true; // 如果点到某条边的距离小于等于阈值,即判定为在边上
				}
			}
            // 该if内的判断基本等价于以下方法,但是该方法经测试会慢一些
            // 这是判断以该点构筑一个边长为1e-6的矩形是否与边界相交的方法
            // return path.intersects(x, y, distanceThreshold, distanceThreshold);
		}
		return false;
	}
}

电子围栏的算法以如下测试用例测试时,有约 120 倍的性能差距,因此在大陆地区的简单电子围栏使用射线法计算即可:

// 1061306
// 点(3.0,4.0)是否在多边形内部? false
// 点(2.0,2.0)是否在多边形内部? true
// 点(-1.0,-1.0)是否在多边形内部? true
// 点(10.0,10.0)是否在多边形内部? false
// 8814
// 点(3.0,4.0)是否在多边形内部? false
// 点(2.0,2.0)是否在多边形内部? true
// 点(-1.0,-1.0)是否在多边形内部? true
// 点(10.0,10.0)是否在多边形内部? false


	public static void main(String[] args) {
		// 示例:判断点(3,4)是否在多边形内部
		double[][] polygon1 = {{1, 1}, {4, 3}, {6, 5}, {8, 6}, {9, 4}, {5, 2}, {1, 1}};
		double x1 = 3;
		double y1 = 4;
		double[][] polygon2 = {{0, 0}, {4, 0}, {4, 4}, {2, 6}, {-2, 4}, {-2, -2}, {0, 0}};
		double x2 = 2;
		double y2 = 2;
		double[][] polygon3 = {{0, 0}, {4, 0}, {4, 4}, {2, 6}, {-2, 4}, {-2, -2}, {0, 0}};
		double x3 = -1;
		double y3 = -1;
		double[][] polygon4 = {{0, 0}, {4, 0}, {4, 4}, {2, 6}, {-2, 4}, {-2, -2}, {0, 0}};
		double x4 = 10;
		double y4 = 10;
		long start = System.nanoTime();
		boolean isInside1 = isPointInPolygonPath2D(x1, y1, polygon1, true);
		boolean isInside2 = isPointInPolygonPath2D(x2, y2, polygon2, true);
		boolean isInside3 = isPointInPolygonPath2D(x3, y3, polygon3, true);
		boolean isInside4 = isPointInPolygonPath2D(x4, y4, polygon4, true);
		System.out.println(System.nanoTime() - start);
		System.out.println("点(" + x1 + "," + y1 + ")是否在多边形内部? " + isInside1);
		System.out.println("点(" + x2 + "," + y2 + ")是否在多边形内部? " + isInside2);
		System.out.println("点(" + x3 + "," + y3 + ")是否在多边形内部? " + isInside3);
		System.out.println("点(" + x4 + "," + y4 + ")是否在多边形内部? " + isInside4);
		start = System.nanoTime();
		isInside1 = isPointInPolygonRayCasting(x1, y1, polygon1);
		isInside2 = isPointInPolygonRayCasting(x2, y2, polygon2);
		isInside3 = isPointInPolygonRayCasting(x3, y3, polygon3);
		isInside4 = isPointInPolygonRayCasting(x4, y4, polygon4);
		System.out.println(System.nanoTime() - start);
		System.out.println("点(" + x1 + "," + y1 + ")是否在多边形内部? " + isInside1);
		System.out.println("点(" + x2 + "," + y2 + ")是否在多边形内部? " + isInside2);
		System.out.println("点(" + x3 + "," + y3 + ")是否在多边形内部? " + isInside3);
		System.out.println("点(" + x4 + "," + y4 + ")是否在多边形内部? " + isInside4);
	}