Files
ios-location-track/core/route_engine.py
T
jbjf6666 85e9a53750 docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖:
- 逐文件可移植性审计(核心逻辑层约 90% 可直接复用)
- 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC +
  ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道
- 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter
  auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel)
- 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico)
- macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils
  放进 excludes、AlreadyMountedError 当成功处理
- 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
2026-10-16 01:05:09 +08:00

577 lines
21 KiB
Python

"""路线引擎:途经点 → 规划路线 → 加密路径 → 速度剖面 → GPX 导入导出。
输出结构(docs/PROTOCOL.md 2.2):
{"waypoints": [[lng,lat],...], "path": [[lng,lat],...], "speeds": [...],
"distance": m, "duration": s, "engine": "osrm"|"straight"|"manual", ...}
播放引擎会用 Route.dense_acc 做弧长插值。
"""
from __future__ import annotations
import json
import math
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Any
from . import geoutils, protocol
MAX_PATH_POINTS = 60000
DEFAULT_STEP_M = 2.0
_OSRM_TIMEOUT = 8.0
_UA = "iOSLocationTool/1.0 (+local mac app)"
@dataclass
class Route:
name: str = "未命名轨迹"
mode: str = "driving"
waypoints: list[tuple[float, float]] = field(default_factory=list) # (lat,lng)
path: list[tuple[float, float]] = field(default_factory=list) # (lat,lng)
speeds: list[float] = field(default_factory=list) # m/s
engine: str = "straight"
loop: bool = False
speed_scale: float = 1.0
_acc: list[float] = field(default_factory=list, repr=False)
# ---------- 派生属性 ----------
@property
def dense_acc(self) -> list[float]:
if not self._acc and self.path:
self._acc = geoutils.cumulative_lengths(self.path)
return self._acc
@property
def distance(self) -> float:
acc = self.dense_acc
return acc[-1] if acc else 0.0
@property
def duration(self) -> float:
"""按速度剖面积分得到的实际耗时(秒)。"""
if len(self.path) < 2 or not self.speeds:
return 0.0
acc = self.dense_acc
total = 0.0
for i in range(1, len(self.path)):
seg = acc[i] - acc[i - 1]
v = (self.speeds[i] + self.speeds[i - 1]) / 2.0
total += seg / v if v > 0.05 else seg / 0.05
return total
def point_at_distance(self, distance: float) -> tuple[float, float]:
if not self.path:
raise protocol.AppError("轨迹为空", "empty_route")
acc = self.dense_acc
point, _ = geoutils.interpolate(self.path, acc, distance)
return point
def speed_at_distance(self, distance: float) -> float:
if not self.speeds or len(self.path) < 2:
return 0.0
acc = self.dense_acc
idx = _index_of(acc, distance)
return self.speeds[min(idx, len(self.speeds) - 1)]
def heading_at_distance(self, distance: float) -> float:
"""行进方向(度,0=正北)。用于让漂移偏移相对于行进方向计算。"""
if len(self.path) < 2:
return 0.0
acc = self.dense_acc
idx = _index_of(acc, distance)
idx = max(0, min(idx, len(self.path) - 2))
a = self.path[idx]
b = self.path[idx + 1]
return geoutils.bearing(a[0], a[1], b[0], b[1])
def highlight(self, distance: float) -> list[list[float]]:
acc = self.dense_acc
pts = geoutils.slice_path(self.path, acc, distance) if len(self.path) > 1 else list(self.path)
return [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in pts]
def to_json(self) -> dict[str, Any]:
return {
"name": self.name,
"mode": self.mode,
"waypoints": [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in self.waypoints],
"path": [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in self.path],
"speeds": [round(v, 3) for v in self.speeds],
"distance": round(self.distance, 2),
"duration": round(self.duration, 2),
"engine": self.engine,
"loop": self.loop,
"speed_scale": self.speed_scale,
}
def _index_of(acc: list[float], distance: float) -> int:
lo, hi = 0, len(acc) - 1
while lo < hi:
mid = (lo + hi) // 2
if acc[mid] <= distance:
lo = mid + 1
else:
hi = mid
return max(0, lo - 1)
class RouteEngine:
def __init__(self, config: Any) -> None:
self.config = config
# ---------------- 构建 ----------------
def build(
self,
waypoint_payloads: list[Any],
mode: str = "driving",
name: str | None = None,
use_osrm: bool = True,
loop: bool = False,
speed_scale: float = 1.0,
) -> Route:
mode = protocol.validate_mode(mode)
speed_scale = protocol.validate_speed_scale(speed_scale)
coords = parse_waypoints(waypoint_payloads)
if len(coords) < 1:
raise protocol.AppError("至少需要一个途经点", "no_waypoints")
engine = "straight"
if len(coords) >= 2 and use_osrm:
planned = self._osrm(coords, mode)
if planned is not None:
coords = planned
engine = "osrm"
route = Route(
name=name or "未命名轨迹",
mode=mode,
engine=engine,
loop=bool(loop),
speed_scale=speed_scale,
)
route.path = self._densify(coords)
route.speeds = compute_speeds(route.path, mode, self._base_speed(mode))
route._acc = geoutils.cumulative_lengths(route.path)
route.waypoints = _pick_waypoints(route.path, parse_waypoints(waypoint_payloads))
return route
def from_coords(
self,
coords: list[tuple[float, float]],
mode: str = "driving",
name: str = "导入轨迹",
engine: str = "manual",
loop: bool = False,
speed_scale: float = 1.0,
) -> Route:
if len(coords) < 1:
raise protocol.AppError("轨迹为空", "no_waypoints")
route = Route(
name=name,
mode=protocol.validate_mode(mode),
engine=engine,
loop=bool(loop),
speed_scale=protocol.validate_speed_scale(speed_scale),
)
route.path = self._densify(coords)
route.speeds = compute_speeds(route.path, route.mode, self._base_speed(route.mode))
route._acc = geoutils.cumulative_lengths(route.path)
route.waypoints = _pick_waypoints(route.path, coords)
return route
def _densify(self, coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
if len(coords) == 1:
return list(coords)
raw = geoutils.densify(coords, DEFAULT_STEP_M, smooth=False)
if len(raw) <= MAX_PATH_POINTS:
return raw
# 超长时按比例降低采样密度,仍保证曲线形状
rough = geoutils.cumulative_lengths(coords)[-1]
step = max(DEFAULT_STEP_M, rough / MAX_PATH_POINTS * 1.05)
out = geoutils.densify(coords, step, smooth=False)
return out[:MAX_PATH_POINTS] if len(out) > MAX_PATH_POINTS else out
def _base_speed(self, mode: str) -> float:
defaults = self.config.get("mode_defaults") or {}
try:
value = float(defaults.get(mode, protocol.MODE_DEFAULTS.get(mode, 13.9)))
except (TypeError, ValueError):
value = protocol.MODE_DEFAULTS.get(mode, 13.9)
return max(0.2, value)
# ---------------- OSRM ----------------
def _osrm(self, coords: list[tuple[float, float]], mode: str) -> list[tuple[float, float]] | None:
profile = {"driving": "driving", "cycling": "bike", "walking": "foot", "flight": "driving"}[mode]
if mode == "flight":
return None
base = str(self.config.get("osrm_url") or "").rstrip("/")
if not base:
return None
coords_str = ";".join(f"{lng:.6f},{lat:.6f}" for lat, lng in coords)
url = f"{base}/{profile}/{coords_str}?overview=full&geometries=geojson&steps=false"
try:
request = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
with urllib.request.urlopen(request, timeout=_OSRM_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8", "replace"))
except Exception:
return None
try:
if payload.get("code") != "Ok":
return None
geometry = payload["routes"][0]["geometry"]["coordinates"]
except (KeyError, IndexError, TypeError):
return None
out: list[tuple[float, float]] = []
for item in geometry:
if isinstance(item, (list, tuple)) and len(item) >= 2:
try:
out.append((float(item[1]), float(item[0])))
except (TypeError, ValueError):
continue
return out if len(out) >= 2 else None
def osrm_available(self) -> bool:
base = str(self.config.get("osrm_url") or "").rstrip("/")
if not base:
return False
try:
request = urllib.request.Request(
base.replace("/route/v1", "") + "/route/v1/driving/116.39,39.90;116.40,39.91?overview=false",
headers={"User-Agent": _UA},
)
with urllib.request.urlopen(request, timeout=4.0) as response:
return response.status == 200
except Exception:
return False
# ---------------- 速度剖面 ----------------
def compute_speeds(
path: list[tuple[float, float]], mode: str, base_speed: float
) -> list[float]:
"""按转弯角度生成速度剖面,并做滑动平均让加减速自然。"""
n = len(path)
if n == 0:
return []
if n == 1:
return [base_speed]
if mode == "flight":
return _smooth([base_speed] * n, window=1)
acc = geoutils.cumulative_lengths(path)
raw = [base_speed] * n
for i in range(1, n - 1):
# 用前后各 ~12 米的点计算航向变化,避免逐点抖动
a = _point_near(path, acc, max(0.0, acc[i] - 12.0))
b = path[i]
c = _point_near(path, acc, acc[i] + 12.0)
angle = abs(_angle_diff(
geoutils.bearing(a[0], a[1], b[0], b[1]),
geoutils.bearing(b[0], b[1], c[0], c[1]),
))
factor = 1.0
if angle >= 100:
factor = 0.12
elif angle >= 60:
factor = 0.25
elif angle >= 35:
factor = 0.45
elif angle >= 18:
factor = 0.68
elif angle >= 8:
factor = 0.86
raw[i] = base_speed * factor
raw[0] = raw[1] if n > 1 else base_speed
raw[-1] = raw[-2] if n > 1 else base_speed
return _smooth(raw, window=max(3, min(41, n // 4 * 2 + 1)))
def _point_near(
path: list[tuple[float, float]], acc: list[float], distance: float
) -> tuple[float, float]:
point, _ = geoutils.interpolate(path, acc, distance)
return point
def _angle_diff(a: float, b: float) -> float:
diff = (b - a + 540.0) % 360.0 - 180.0
return diff
def _smooth(values: list[float], window: int) -> list[float]:
n = len(values)
if n <= 2 or window <= 1:
return [round(v, 3) for v in values]
half = max(1, window // 2)
out: list[float] = []
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
chunk = values[lo:hi]
out.append(round(sum(chunk) / len(chunk), 3))
return out
# ---------------- 途经点处理 ----------------
MAX_WAYPOINTS = 5000
MAX_REQUEST_POINTS = 200000
def parse_waypoints(payloads: Any, limit: int = MAX_WAYPOINTS) -> list[tuple[float, float]]:
"""把 [[lng,lat], ...] 或 [{"lng":..,"lat":..}, ...] 统一成 [(lat,lng)]。"""
if payloads is None:
return []
if not isinstance(payloads, (list, tuple)):
raise protocol.AppError("waypoints 必须是数组", "bad_waypoints")
if len(payloads) > limit:
raise protocol.AppError(
f"坐标点过多({len(payloads)} 个,上限 {limit} 个):请减少途经点或改用 GPX 导入",
"too_many_points",
status=413,
)
out: list[tuple[float, float]] = []
for item in payloads:
if isinstance(item, dict):
lat = item.get("lat")
lng = item.get("lng", item.get("lon"))
elif isinstance(item, (list, tuple)) and len(item) >= 2:
lng, lat = item[0], item[1]
else:
raise protocol.AppError("途经点格式应为 [经度, 纬度]", "bad_waypoints")
flat, flng = protocol.validate_latlng(lat, lng)
out.append((flat, flng))
return out
def _pick_waypoints(
path: list[tuple[float, float]], wanted: list[tuple[float, float]]
) -> list[tuple[float, float]]:
"""用户可能只给了稀疏的途经点,这里保留原始途经点作为列表展示。"""
return list(wanted) if wanted else list(path[:1] + path[-1:] if len(path) > 1 else path)
# ---------------- GPX ----------------
GPX_NS = "http://www.topografix.com/GPX/1/1"
def to_gpx(route: Route, creator: str = protocol.APP_NAME) -> str:
root = ET.Element(
"gpx",
{
"version": "1.1",
"creator": creator,
"xmlns": GPX_NS,
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
"xsi:schemaLocation": f"{GPX_NS} http://www.topografix.com/GPX/1/1/gpx.xsd",
},
)
metadata = ET.SubElement(root, "metadata")
ET.SubElement(metadata, "name").text = route.name or "未命名轨迹"
ET.SubElement(metadata, "desc").text = (
f"{protocol.MODE_LABELS.get(route.mode, route.mode)}轨迹,"
f"距离 {route.distance / 1000:.2f} km,预计耗时 {_fmt_duration(route.duration)}"
)
ET.SubElement(metadata, "time").text = _iso_now()
for index, (lat, lng) in enumerate(route.waypoints, start=1):
wpt = ET.SubElement(root, "wpt", {"lat": f"{lat:.7f}", "lon": f"{lng:.7f}"})
ET.SubElement(wpt, "name").text = f"途经点 {index}"
ET.SubElement(wpt, "sym").text = "Flag, Blue"
trk = ET.SubElement(root, "trk")
ET.SubElement(trk, "name").text = route.name or "未命名轨迹"
ET.SubElement(trk, "type").text = protocol.MODE_LABELS.get(route.mode, route.mode)
seg = ET.SubElement(trk, "trkseg")
acc = route.dense_acc
for i, (lat, lng) in enumerate(route.path):
trkpt = ET.SubElement(seg, "trkpt", {"lat": f"{lat:.7f}", "lon": f"{lng:.7f}"})
if i < len(route.speeds):
ET.SubElement(trkpt, "speed").text = f"{route.speeds[i]:.3f}"
if acc:
ET.SubElement(trkpt, "time").text = _iso_offset(acc[i] / max(0.2, _avg_speed(route)))
_indent(root)
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
def from_gpx(text: str) -> tuple[list[tuple[float, float]], str, str]:
"""解析 GPX,返回 (坐标[(lat,lng)], 名称, 类型)。优先 trkseg,其次 rte,最后 wpt。"""
try:
root = ET.fromstring(text)
except ET.ParseError as exc:
raise protocol.AppError("GPX 文件解析失败,请确认文件格式", "bad_gpx") from exc
tag = _strip_ns(root.tag)
if tag != "gpx":
raise protocol.AppError("这不是一个 GPX 文件", "bad_gpx")
name = ""
for child in root:
if _strip_ns(child.tag) == "metadata":
for item in child:
if _strip_ns(item.tag) == "name" and item.text:
name = item.text.strip()
break
if name:
break
coords: list[tuple[float, float]] = []
for child in root:
ctag = _strip_ns(child.tag)
if ctag == "trk":
if not name:
for item in child:
if _strip_ns(item.tag) == "name" and item.text:
name = item.text.strip()
break
for seg in child:
if _strip_ns(seg.tag) != "trkseg":
continue
for point in seg:
parsed = _parse_point(point)
if parsed:
coords.append(parsed)
elif ctag == "rte":
for point in child:
if _strip_ns(point.tag) == "rtept":
parsed = _parse_point(point)
if parsed:
coords.append(parsed)
if not coords:
for child in root:
if _strip_ns(child.tag) == "wpt":
parsed = _parse_point(child)
if parsed:
coords.append(parsed)
if not coords:
raise protocol.AppError("GPX 里没有可用的轨迹点", "bad_gpx")
# 不做抽稀:GPX 是需要保形重放的轨迹,丢点会导致导入后形状变化(仅在极端点数时等间隔抽稀)
coords = _thin_if_huge(coords)
return coords, (name or "导入的 GPX 轨迹"), "gpx"
def _thin_if_huge(coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""仅在点数极多时按等间隔抽稀,保证不丢形状(正常大小的 GPX 原样返回)。"""
if len(coords) <= MAX_PATH_POINTS:
return coords
step = len(coords) / float(MAX_PATH_POINTS)
out: list[tuple[float, float]] = []
index = 0.0
while int(index) < len(coords):
out.append(coords[int(index)])
index += step
if out[-1] != coords[-1]:
out.append(coords[-1])
return out
def _parse_point(node: ET.Element) -> tuple[float, float] | None:
lat = node.get("lat")
lng = node.get("lon")
if lat is None or lng is None:
return None
try:
flat, flng = protocol.validate_latlng(float(lat), float(lng))
except (protocol.AppError, TypeError, ValueError):
return None
return flat, flng
def _strip_ns(tag: str) -> str:
return tag.split("}", 1)[1] if "}" in tag else tag
def _avg_speed(route: Route) -> float:
if not route.speeds:
return 1.0
return max(0.2, sum(route.speeds) / len(route.speeds))
def _fmt_duration(seconds: float) -> str:
total = int(max(0, round(seconds)))
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
if hours:
return f"{hours} 小时 {minutes} 分"
if minutes:
return f"{minutes} 分 {secs} 秒"
return f"{secs} 秒"
def _iso_now() -> str:
import datetime
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _iso_offset(seconds: float) -> str:
import datetime
moment = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=float(seconds))
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
def _indent(elem: ET.Element, level: int = 0) -> None:
pad = "\n" + " " * level
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = pad + " "
for child in elem:
_indent(child, level + 1)
if not child.tail or not child.tail.strip():
child.tail = pad
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = pad
def haversine_total(path: list[tuple[float, float]]) -> float:
if len(path) < 2:
return 0.0
return geoutils.cumulative_lengths(path)[-1]
def sanitize_route_payload(payload: Any) -> Route:
"""前端 play.start 直接传 route 时,从 payload 还原 Route 对象。"""
if not isinstance(payload, dict):
raise protocol.AppError("route 必须是对象", "bad_route")
path_raw = payload.get("path") or []
path = parse_waypoints(path_raw, limit=MAX_REQUEST_POINTS)
if len(path) < 1:
waypoints = parse_waypoints(payload.get("waypoints") or [])
if len(waypoints) < 1:
raise protocol.AppError("轨迹为空,请先添加途经点或绘制轨迹", "empty_route")
path = waypoints
speeds_raw = payload.get("speeds") or []
mode = protocol.validate_mode(payload.get("mode"))
route = Route(
name=str(payload.get("name") or "未命名轨迹")[:80],
mode=mode,
waypoints=parse_waypoints(payload.get("waypoints") or []) or [path[0], path[-1]],
path=path,
engine=str(payload.get("engine") or "manual")[:16],
loop=bool(payload.get("loop")),
speed_scale=protocol.validate_speed_scale(payload.get("speed_scale") or 1.0),
)
if len(speeds_raw) == len(path):
base = protocol.MODE_DEFAULTS.get(mode, 13.9)
speeds: list[float] = []
for value in speeds_raw:
# 非有限数(NaN/Infinity)或越界值一律回退到基准速度,避免污染整条链路
try:
number = float(value)
except (TypeError, ValueError):
speeds.append(base)
continue
if not math.isfinite(number) or not (0.05 <= number <= 400.0):
speeds.append(base)
else:
speeds.append(number)
route.speeds = speeds
else:
route.speeds = compute_speeds(path, mode, protocol.MODE_DEFAULTS.get(mode, 13.9))
route._acc = geoutils.cumulative_lengths(path)
return route
def default_route_json(engine: RouteEngine, waypoints: list[Any], mode: str = "driving") -> dict[str, Any]:
return engine.build(waypoints, mode=mode).to_json()