This commit is contained in:
Horldsence
2026-09-13 10:27:09 +08:00
parent b810ec7777
commit 82311a47cc
52 changed files with 2563 additions and 1278 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
``referee-data/`` 与 ``output/`` 都在 .gitignore 里:本机清掉评测机数据后,
``docs/figures/*.png`` 就无法复现。本脚本把"作图用到的那几局"抽出来,
命名成 ``<case_code>.<module>.jsonl`` 存进 ``examples/replay/``(入库、几万行以内),
``scripts/make_figures.py`` 在没有评测机数据时自动改读这里。
``scripts/build_figure_data.py`` 在没有评测机数据时自动改读这里。
uv run python scripts/archive_replay_logs.py # 归档作图默认选中的两局
uv run python scripts/archive_replay_logs.py BBM9-HZG2-77N5-NKKC # 指定案例
@@ -26,7 +26,7 @@ ARCHIVE_DIR = ROOT / 'examples' / 'replay'
sys.path.insert(0, str(Path(__file__).resolve().parent))
from make_figures import discover_runs, pick_run # noqa: E402
from build_figure_data import discover_runs, pick_run # noqa: E402
def archive(run: dict, out_dir: Path) -> Path:
+747
View File
@@ -0,0 +1,747 @@
"""为 MATLAB 出图准备数据,并更新 docs/figures/manifest.json。
uv run python scripts/build_figure_data.py --prepare # 只写 .mat 数据
uv run python scripts/build_figure_data.py # 写数据 + 调 MATLAB 出图
uv run python scripts/build_figure_data.py --only geo-q3-seven-point-coverage
uv run python scripts/build_figure_data.py --list
分工:
- **本脚本(Python)**:解析行为日志与覆盖计划、调用生产求解器算几何、把绘图数据写成
``docs/figures/.data/*.mat``、维护 manifest(来源路径 + sha256 + 行数);
- **MATLAB**:``scripts/matlab/*.m`` 负责排版与出图,只读 .mat,不做数据解析。
手动出图:MATLAB 中 ``cd scripts/matlab`` 后执行 ``run_all_figures``。
图内不放标题、图注与解释性文字,只保留坐标轴、图例与数据标注;
每张图的含义由文件名与 ``docs/figures/README.md`` 承担。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import subprocess
import sys
from pathlib import Path
from typing import Any, Callable, Sequence
import numpy as np
from scipy.io import savemat
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "docs" / "figures"
DATA_DIR = OUT_DIR / ".data"
MATLAB_DIR = ROOT / "scripts" / "matlab"
SESSIONS_DIR = ROOT / "referee-data" / "sessions"
ARCHIVE_DIR = ROOT / "examples" / "replay"
MATLAB_BIN = Path("/Applications/MATLAB_R2025a.app/bin/matlab")
# 题面常量(与 src/mcm26b/config.py 一致;此处只用于画图)
OMEGA_R = 1800.0
R_EFF = 1000.0
SPEED_MPS = 5.0
MEASURE_S = 5.0
SWITCH_S = 1.0
CLEAR_OK_S = 5.0
CLEAR_NO_S = 3.0
RING_RADIUS_M = 1159.7
# 回放图指定的 q4 演练局(缺失时回退到自动挑选)
Q4_REPLAY_CASE = "GHSN-M38E-74GD-YLUD"
SEVEN_R = 1800.0
# 图名 → (MATLAB 函数, .mat 数据名)
FIGURES: tuple[tuple[str, str, str], ...] = (
("flow-overall-architecture", "fig_flow_overall", "flow_overall"),
("steps-state-machine", "fig_steps_state_machine", "steps_state_machine"),
("timing-attachment-example-105-111-194-199", "fig_timing_attachment",
"timing_attachment"),
("timing-measured-q3-10-sources", "fig_timing_measured", "timing_measured"),
("geo-q1-convex-intersection-zoom", "fig_geo_q1_meetup", "geo_q1_meetup"),
("geo-q1-diameter-circle-counterexample", "fig_geo_q1_counterexample",
"geo_q1_counterexample"),
("geo-q2-guaranteed-reception-disk", "fig_geo_q2_safe_disk", "geo_q2"),
("geo-q3-seven-point-coverage", "fig_geo_q3_coverage", "geo_q3"),
("geo-q4-minimum-cover-31-points", "fig_geo_q4_mincover", "geo_q4"),
("replay-webui-q3-omnidirectional", "fig_replay", "replay_q3"),
("replay-webui-q4-directional", "fig_replay", "replay_q4"),
)
SOURCES: dict[str, list[str]] = {
"flow-overall-architecture": ["src/mcm26b/config.py", "docs/B题建模分析与实施计划.md"],
"steps-state-machine": ["docs/协议规则表与状态机.md", "src/mcm26b/protocol/client.py"],
"timing-attachment-example-105-111-194-199": ["docs/协议规则表与状态机.md"],
"geo-q1-diameter-circle-counterexample": ["examples/q1/triangle.json"],
"geo-q2-guaranteed-reception-disk": ["src/mcm26b/config.py", "docs/algo-2.md"],
"geo-q3-seven-point-coverage": ["src/mcm26b/planner/seven.py", "docs/algo-3.md"],
"geo-q4-minimum-cover-31-points": [
"src/mcm26b/guarantee/plans/q4_mincover_v2.json", "docs/algo-4.md"],
}
# ---------------------------------------------------------------------------
# 数据来源
# ---------------------------------------------------------------------------
def source(path: str | Path, **extra: Any) -> dict[str, Any]:
p = Path(path)
p = p if p.is_absolute() else ROOT / p
digest, lines = "-", 0
if p.is_file():
data = p.read_bytes()
digest = hashlib.sha256(data).hexdigest()[:12]
lines = data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
rel = str(p.relative_to(ROOT)) if p.is_relative_to(ROOT) else str(p)
return {"path": rel, "sha256_12": digest, "lines": lines, **extra}
def parse_jsonl(path: Path) -> dict[str, Any]:
"""兼容评测机 ``event=action`` 与客户端 ``event=accepted`` 两种日志。"""
meta: dict[str, Any] = {}
actions: list[dict[str, Any]] = []
fallback: dict[str, dict[str, Any]] = {}
seq = 0
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
kind = ev.get("event")
if kind == "session_start":
meta = ev
elif kind == "action":
seq += 1
actions.append(dict(seq=ev.get("seq", seq), path=ev.get("path", ""),
request=ev.get("request") or {},
response=ev.get("response") or {},
wall_ts_ms=ev.get("wall_ts_ms")))
elif kind == "accepted":
fallback[str(ev.get("request_id", ""))] = ev
elif kind == "step" and str(ev.get("request_id", "")) not in fallback:
fallback[str(ev.get("request_id", ""))] = ev
if not actions:
for ev in fallback.values():
if not ev.get("path"):
continue
seq += 1
stamp = ev.get("logged_at_unix_s")
actions.append(dict(seq=seq, path=ev["path"], request=ev.get("request") or {},
response=ev.get("response") or {},
wall_ts_ms=stamp * 1000 if stamp else None))
if not actions:
raise ValueError(f"{path} 没有可回放的动作事件")
return dict(case_code=meta.get("case_code") or path.parent.name,
module=meta.get("module") or "?", robot_id=str(meta.get("robot_id") or "?"),
truth=list(meta.get("truth") or []), actions=actions, source=path)
def discover_runs() -> list[dict[str, Any]]:
runs: list[dict[str, Any]] = []
if SESSIONS_DIR.is_dir():
for log in sorted(SESSIONS_DIR.glob("*/log.jsonl")):
try:
runs.append(parse_jsonl(log))
except ValueError:
continue
if not runs and ARCHIVE_DIR.is_dir():
for log in sorted(ARCHIVE_DIR.glob("*.jsonl")):
try:
runs.append(parse_jsonl(log))
except ValueError:
continue
return runs
def pick_case(runs: Sequence[dict[str, Any]], case_code: str) -> dict[str, Any] | None:
"""按案例编码取局;不在本机留档里就返回 None,由调用方回退。"""
return next((r for r in runs if r["case_code"] == case_code), None)
def pick_run(runs: Sequence[dict[str, Any]], module: str, truth: int) -> dict[str, Any]:
cands = [r for r in runs if r["module"].startswith(module) and len(r["truth"]) >= truth
and any(a["path"] == "/measure" for a in r["actions"])]
if not cands:
cands = [r for r in runs if r["module"].startswith(module)]
return sorted(cands, key=lambda r: (abs(len(r["truth"]) - truth), len(r["actions"])))[0]
def decompose(run: dict[str, Any]) -> dict[str, Any]:
"""按附件规则逐动作复算移动 / 换频 / 动作耗时。"""
pos = (0.0, 0.0)
channel: int | None = None
rows: list[dict[str, Any]] = []
for act in run["actions"]:
req, res = act["request"], act["response"]
move_m = switch = action_s = 0.0
if act["path"] in ("/measure", "/clear") and "position" in req:
tgt = (float(req["position"]["x"]), float(req["position"]["y"]))
move_m = math.dist(pos, tgt)
if move_m > 0:
pos = tgt
if act["path"] == "/measure":
ch = int(req.get("channel", 0))
switch = 0.0 if channel in (None, ch) else SWITCH_S
channel = ch
action_s = MEASURE_S
elif act["path"] == "/clear":
action_s = CLEAR_OK_S if str(res.get("clear_result")) == "success" else CLEAR_NO_S
rows.append(dict(path=act["path"], move_s=move_m / SPEED_MPS, switch_s=switch,
action_s=action_s, move_m=move_m,
virtual_s=float(res.get("virtual_time_s") or 0.0),
hit=str(res.get("measure_result")) == "direction",
cleared=str(res.get("clear_result")) == "success",
channel=req.get("channel"),
x=req.get("position", {}).get("x"),
y=req.get("position", {}).get("y")))
last = run["actions"][-1]["response"]
return dict(rows=rows, virtual_s=float(last.get("virtual_time_s") or 0.0),
move_m=sum(r["move_m"] for r in rows),
measures=sum(1 for r in rows if r["path"] == "/measure"),
clears=sum(1 for r in rows if r["path"] == "/clear"))
def solved_channels(run: dict[str, Any]) -> list[int]:
return sorted({int(a["request"]["channel"]) for a in run["actions"]
if a["path"] == "/clear" and "channel" in a["request"]
and str(a["response"].get("clear_result")) == "success"})
# ---------------------------------------------------------------------------
# 几何:半平面裁剪与问题一求解
# ---------------------------------------------------------------------------
def clip_halfplane(poly: np.ndarray, a: float, b: float, c: float) -> np.ndarray:
"""Sutherland–Hodgman:半平面 a·x + b·y <= c 裁剪凸多边形。"""
if len(poly) == 0:
return poly
out: list[list[float]] = []
n = len(poly)
for i in range(n):
cur, nxt = poly[i], poly[(i + 1) % n]
dc = a * cur[0] + b * cur[1] - c
dn = a * nxt[0] + b * nxt[1] - c
if dc <= 1e-9:
out.append(list(cur))
if dn > 1e-9:
t = dc / (dc - dn)
out.append([cur[0] + t * (nxt[0] - cur[0]),
cur[1] + t * (nxt[1] - cur[1])])
elif dn <= 1e-9:
t = dc / (dc - dn)
out.append([cur[0] + t * (nxt[0] - cur[0]),
cur[1] + t * (nxt[1] - cur[1])])
return np.array(out) if out else np.empty((0, 2))
def angle_error(poly: np.ndarray, station: Sequence[float], bearing: float) -> float:
centre = poly.mean(axis=0)
dx, dy = centre[0] - station[0], centre[1] - station[1]
if abs(dx) < 1e-12 and abs(dy) < 1e-12:
return 0.0
return abs((math.degrees(math.atan2(dy, dx)) - bearing + 180.0) % 360.0 - 180.0)
def clip_wedge(region: np.ndarray, station: Sequence[float], bearing: float,
half_deg: float) -> np.ndarray:
"""用角域 W(s, θ ± δ) 裁剪区域:两个半平面各试一次,取方向一致的那一侧。"""
region = np.asarray(region, dtype=float)
if len(region) == 0:
return region
for edge in (bearing - half_deg, bearing + half_deg):
ang = math.radians(edge)
a, b = -math.sin(ang), math.cos(ang)
c = a * station[0] + b * station[1]
cands = [cand for cand in (clip_halfplane(region, a, b, c),
clip_halfplane(region, -a, -b, -c)) if len(cand) >= 3]
if not cands:
return np.empty((0, 2))
region = cands[0] if len(cands) == 1 else min(
cands, key=lambda cand: angle_error(cand, station, bearing))
return region
def wedge_polygon(station: Sequence[float], bearing: float, half_deg: float,
reach: float) -> np.ndarray:
a0, a1 = math.radians(bearing - half_deg), math.radians(bearing + half_deg)
ang = np.linspace(a0, a1, 24)
return np.vstack([[station[0], station[1]],
np.column_stack([station[0] + reach * np.cos(ang),
station[1] + reach * np.sin(ang)])])
def overview_window(stations: np.ndarray, verts: np.ndarray,
dc: dict[str, Any], mec: dict[str, Any]) -> tuple[float, np.ndarray]:
"""总览窗口:包含全部站点、定位区域与两个圆,中心取包围盒中心。"""
pts = [np.asarray(stations, dtype=float), np.asarray(verts, dtype=float)]
for circ in (dc, mec):
c = np.asarray(circ["center"], dtype=float)
r = float(circ["radius_m"])
pts.append(np.array([c + r, c - r]))
all_pts = np.vstack(pts)
lo, hi = all_pts.min(axis=0), all_pts.max(axis=0)
center = (lo + hi) / 2
half = float(np.max(hi - lo) / 2 * 1.12 + 40.0)
return half, center
def q1_solve(stations: Sequence[Sequence[float]]) -> dict[str, Any]:
"""调用生产求解器,保证图中数字与代码一致。"""
sys.path.insert(0, str(ROOT / "src"))
from mcm26b import Measurement, solve # noqa: PLC0415
res = solve([Measurement(x, y, b, h) for x, y, b, h in stations])
dc = res["diameter_circle"]
mec = res["minimum_enclosing_circle"]
return dict(vertices=np.array(res["vertices"]),
dc=dict(center=np.array(dc["center"]), radius_m=float(dc["radius_m"])),
mec=dict(center=np.array(mec["center"]), radius_m=float(mec["radius_m"])))
def q1_practice_case() -> dict[str, Any] | None:
for path in sorted((ROOT / "output" / "q1" / "practice").glob("*_observations.json")):
doc = json.loads(path.read_text(encoding="utf-8"))
if doc.get("result"):
doc["_path"] = path
return doc
return None
def load_plan(name: str) -> dict[str, Any]:
path = ROOT / "src" / "mcm26b" / "guarantee" / "plans" / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
def seven_bound(a: float = RING_RADIUS_M, radius: float = SEVEN_R) -> float:
return max(a / math.sqrt(3),
math.sqrt(radius ** 2 + a ** 2 - math.sqrt(3) * radius * a))
# ---------------------------------------------------------------------------
# 各图数据
# ---------------------------------------------------------------------------
def data_flow_overall() -> dict[str, Any]:
"""四个层次带:层标签、每层的框内短标签与语义色名。"""
bands = [
("L0", [("区域 Ω:半径 1800 m", 8.0, 26.0),
("移动 5 m/s · 检测 5 s", 36.5, 26.0),
("换频 1 s · 清除 5/3 s", 65.0, 26.0),
("源 10~16 个 · 频道 1~20", 93.5, 24.0)]),
("L1", [("证据层:候选集与粒子幸存集", 8.0, 34.0),
("决策层:选点与任务表", 45.0, 34.0),
("执行层:串行客户端", 82.0, 34.0)]),
("L2", [("问题 1 定位解算", 8.0, 26.0),
("问题 2 第二点选择", 36.5, 26.0),
("问题 3 全向搜索", 65.0, 26.0),
("问题 4 混合定向", 93.5, 24.0)]),
("L3", [("发现闭环", 8.0, 34.0),
("定位闭环", 45.0, 34.0),
("清除闭环", 82.0, 34.0)]),
]
colors = {"L0": "primary", "L1": "primary", "L2": "accent", "L3": "success"}
return dict(tags=[b[0] for b in bands],
labels=[[i[0] for i in b[1]] for b in bands],
colors=[colors[b[0]] for b in bands])
def data_steps_state_machine() -> dict[str, Any]:
return dict(labels=["IDLE", "倒计时 5 s", "OPEN 接口开放", "CLOSED 断连"],
edges=["muted", "warn", "success", "accent"])
EXAMPLE_STEPS = (("/enter", 0.0, 0.0, 0.0),
("/measure ch1", 100.0, 0.0, 5.0),
("/measure ch2", 0.0, 1.0, 5.0),
("/clear ch3", 80.0, 0.0, 3.0),
("/measure ch2", 0.0, 0.0, 5.0),
("/exit", 0.0, 0.0, 0.0))
def data_timing_attachment() -> dict[str, Any]:
return dict(labels=[s[0] for s in EXAMPLE_STEPS],
move=np.array([s[1] for s in EXAMPLE_STEPS]),
switch_s=np.array([s[2] for s in EXAMPLE_STEPS]),
action=np.array([s[3] for s in EXAMPLE_STEPS]),
xlabel="虚拟耗时 / s",
legend=["移动 L/5", "换频 1 s", "检测/清除动作"])
def data_timing_measured(run: dict[str, Any]) -> dict[str, Any]:
tl = decompose(run)
rows = tl["rows"]
seq = np.arange(1.0, len(rows) + 1.0)
steps = np.array([[r["move_s"], r["action_s"], r["switch_s"]] for r in rows],
dtype=float)
cum = np.cumsum(np.cumsum(steps, axis=1), axis=0) # 移动 / +动作 / +换频
return dict(seq=seq, cum=cum,
xlabel="动作序号", ylabel="累计虚拟时间 / s",
legend=["移动", "检测/清除动作", "换频"])
def data_geo_q1_meetup() -> tuple[dict[str, Any], Path]:
case = q1_practice_case()
if case:
stations = [(m["x"], m["y"], m["bearing_deg"], m["half_angle_deg"])
for m in case["measurements"]]
res = case["result"]
verts = np.array(res["vertices"])
dc = dict(center=np.array(res["diameter_circle"]["center"]),
radius_m=float(res["diameter_circle"]["radius_m"]))
mec = dict(center=np.array(res["minimum_enclosing_circle"]["center"]),
radius_m=float(res["minimum_enclosing_circle"]["radius_m"]))
src = case["_path"]
else:
src = ROOT / "examples" / "q1" / "triangle.json"
doc = json.loads(src.read_text(encoding="utf-8"))
stations = [(m["x"], m["y"], m["bearing_deg"], m.get("half_angle_deg", 1.0))
for m in doc["measurements"]]
sol = q1_solve(stations)
verts, dc, mec = sol["vertices"], sol["dc"], sol["mec"]
zoom_c = verts.mean(axis=0)
span = float(np.abs(verts - zoom_c).max())
zoom = max(1.8 * span, 0.8 * mec["radius_m"] + span, 12.0)
xs = [s[0] for s in stations]
ys = [s[1] for s in stations]
half, view_c = overview_window(np.array([[s[0], s[1]] for s in stations]),
verts, dc, mec)
reach = 2.4 * half
wedges = [wedge_polygon((sx, sy), b, hd, reach) for sx, sy, b, hd in stations]
region = np.array([[zoom_c[0] - zoom, zoom_c[1] - zoom],
[zoom_c[0] + zoom, zoom_c[1] - zoom],
[zoom_c[0] + zoom, zoom_c[1] + zoom],
[zoom_c[0] - zoom, zoom_c[1] + zoom]], dtype=float)
for sx, sy, b, hd in stations:
region = clip_wedge(region, (sx, sy), b, hd)
data = dict(
stations=np.array([[s[0], s[1]] for s in stations]),
station_labels=[f"s{i + 1}" for i in range(len(stations))],
wedges=np.stack(wedges), zoom_wedges=np.stack(wedges),
zoom_region=region,
dc_center=dc["center"], dc_radius=dc["radius_m"],
mec_center=mec["center"], mec_radius=mec["radius_m"],
view_center=view_c, half_overview=float(half),
zoom_center=zoom_c, zoom_half=float(zoom),
legend1=["前向角域 W(s, θ ± 1°)", "定位区域 P"],
legend2=["直径圆", "最小包围圆 MEC"])
return data, Path(src)
def data_geo_q1_counterexample() -> dict[str, Any]:
src = ROOT / "examples" / "q1" / "triangle.json"
doc = json.loads(src.read_text(encoding="utf-8"))
stations = [(m["x"], m["y"], m["bearing_deg"], m.get("half_angle_deg", 1.0))
for m in doc["measurements"]]
sol = q1_solve(stations)
verts, dc, mec = sol["vertices"], sol["dc"], sol["mec"]
zoom_c = verts.mean(axis=0)
zoom = max(1.9 * float(np.abs(verts - zoom_c).max()) + mec["radius_m"], 45.0)
half, view_c = overview_window(np.array([[s[0], s[1]] for s in stations]),
verts, dc, mec)
wedges = [wedge_polygon((sx, sy), b, hd, 3600.0) for sx, sy, b, hd in stations]
region = np.array([[zoom_c[0] - zoom, zoom_c[1] - zoom],
[zoom_c[0] + zoom, zoom_c[1] - zoom],
[zoom_c[0] + zoom, zoom_c[1] + zoom],
[zoom_c[0] - zoom, zoom_c[1] + zoom]], dtype=float)
for sx, sy, b, hd in stations:
region = clip_wedge(region, (sx, sy), b, hd)
return dict(
stations=np.array([[s[0], s[1]] for s in stations]),
station_labels=[f"s{i + 1}" for i in range(len(stations))],
wedges=np.stack(wedges), zoom_wedges=np.stack(wedges),
zoom_region=region,
dc_center=dc["center"], dc_radius=dc["radius_m"],
mec_center=mec["center"], mec_radius=mec["radius_m"],
view_center=view_c, half_overview=float(half),
zoom_center=zoom_c, zoom_half=float(zoom),
legend1=["前向角域 W(s, θ ± 1°)", "定位区域 P"],
legend2=["直径圆", "最小包围圆 MEC"])
def data_geo_q2() -> dict[str, Any]:
a = 1500.0 / (2 * math.cos(math.radians(1.0)))
safe = R_EFF - a
c = np.array([a * math.cos(math.radians(30.0)), a * math.sin(math.radians(30.0))])
return dict(omega_r=OMEGA_R, wedge=wedge_polygon((0.0, 0.0), 30.0, 1.0, 1500.0),
a=float(a), safe=float(safe), c=c, half=1900.0,
legend=["前向角域 W(s1)", "截断角域外接圆 B(c, a)",
"安全候选盘 B(c, 1000 − a)"])
def data_geo_q3() -> dict[str, Any]:
a = RING_RADIUS_M
stations = np.array([[0.0, 0.0]] + [[a * math.cos(k * math.pi / 3),
a * math.sin(k * math.pi / 3)] for k in range(6)])
return dict(omega_r=OMEGA_R, r_eff=R_EFF,
stations=stations,
worst=np.array([0.0, -SEVEN_R]),
half=float(RING_RADIUS_M + R_EFF + 90.0),
legend=["区域 Ω", "1000 m 接收盘", "覆盖站点", "最差点"])
def data_geo_q4() -> dict[str, Any]:
plan = load_plan("q4_mincover_v2")
pts = np.array(plan["points"], dtype=float)
theta = np.linspace(-math.pi / 2, math.pi / 2, 60)
half_disk = np.vstack([[0.0, 0.5],
np.column_stack([0.85 * np.cos(theta),
0.5 + 0.85 * np.sin(theta)])])
half = float(max(OMEGA_R, np.abs(pts).max()) * 1.06)
return dict(omega_r=OMEGA_R, points=pts, half=half, half_disk=half_disk,
radius_label="覆盖半径 1000 m",
heading_label="前向半盘 ±90°(朝向未知)",
legend=["区域 Ω", "31 点最小命中集"])
def data_replay(run: dict[str, Any], *, directional: bool) -> dict[str, Any]:
tl = decompose(run)
rows = tl["rows"]
truth = run["truth"]
solved = set(solved_channels(run))
truth_pts, truth_solved, truth_labels = [], [], []
for src in truth:
ch = int(src["channel"])
truth_pts.append([src["x"], src["y"]])
truth_solved.append(1.0 if ch in solved else 0.0)
truth_labels.append(f"ch{ch}")
# 定向源:朝向箭头(长度取该源有效接收半径)+ ±90° 前向半盘
headings, half_disks = [], []
for src in truth:
if not (directional and src.get("directional")):
continue
r = float(src.get("r_eff_m") or R_EFF)
ang = math.radians(float(src.get("heading_deg") or 0.0))
headings.append([src["x"], src["y"], r * math.cos(ang), r * math.sin(ang)])
arc = ang + np.linspace(-math.pi / 2, math.pi / 2, 49)
half_disks.append(np.vstack([[src["x"], src["y"]],
np.column_stack([src["x"] + r * np.cos(arc),
src["y"] + r * np.sin(arc)])]))
half_disk_arr = (np.stack(half_disks) if half_disks
else np.zeros((0, 50, 2), dtype=float))
path = [(0.0, 0.0)]
rays, clears, marks, events = [], [], [], []
for act, row in zip(run["actions"], rows):
if act["path"] == "/measure" and row["hit"]:
bearing = math.radians(float(act["response"].get("svd_deg") or 0.0))
rays.append([row["x"], row["y"], 220 * math.cos(bearing),
220 * math.sin(bearing)])
if act["path"] in ("/measure", "/clear") and row["x"] is not None:
if (row["x"], row["y"]) != path[-1]:
path.append((row["x"], row["y"]))
if act["path"] == "/clear":
clears.append([row["x"], row["y"], 1.0 if row["cleared"] else 0.0])
marks.append([row["virtual_s"], 1.0 if row["hit"] else 0.0,
1.0 if act["path"] == "/clear" and row["cleared"] else 0.0])
if len(events) < 10:
if act["path"] == "/measure":
detail = f"ch{row['channel']} ({row['x']:.0f},{row['y']:.0f})"
elif act["path"] == "/clear":
detail = f"ch{row['channel']} {'成功' if row['cleared'] else '未发现'}"
else:
detail = "启动会话" if act["path"] == "/enter" else "退出"
events.append([act["path"], detail, f"{row['virtual_s']:.0f}"])
legend = ["移动轨迹", "检测示向度", "已清除频道", "未清除频道"]
if directional:
legend = ["移动轨迹", "检测示向度", "定向源前向半盘 (±90°)",
"已清除频道", "未清除频道"]
# 世界视图窗口:容纳 Ω、轨迹、射线与定向半盘,避免半盘被画到框外
geom = [np.asarray(truth_pts, dtype=float), np.asarray(path, dtype=float)]
if rays:
rays_arr = np.asarray(rays, dtype=float)
geom.append(rays_arr[:, :2] + rays_arr[:, 2:])
if half_disk_arr.size:
geom.append(half_disk_arr.reshape(-1, 2))
half = float(max(OMEGA_R, np.abs(np.vstack(geom)).max()) * 1.06)
return dict(
omega_r=OMEGA_R, half=half,
out_png=("replay-webui-q4-directional.png" if directional
else "replay-webui-q3-omnidirectional.png"),
truth=np.array(truth_pts, dtype=float),
truth_solved=np.array(truth_solved, dtype=float).reshape(-1, 1),
truth_labels=truth_labels,
headings=np.array(headings, dtype=float).reshape(-1, 4),
half_disks=half_disk_arr,
path=np.array(path, dtype=float),
rays=np.array(rays, dtype=float).reshape(-1, 4),
clears=np.array(clears, dtype=float).reshape(-1, 3),
marks=np.array(marks, dtype=float).reshape(-1, 3),
events=events, total_s=float(tl["virtual_s"]), legend=legend)
# ---------------------------------------------------------------------------
# 产出
# ---------------------------------------------------------------------------
def _as_cell(values: Any) -> np.ndarray:
"""字符串列表 → object 数组(MATLAB cell);纯数值嵌套列表 → 数值矩阵。"""
if isinstance(values, str):
return np.array([values], dtype=object)
if isinstance(values, (list, tuple)):
if values and all(isinstance(v, (list, tuple)) and
all(isinstance(x, (int, float)) for x in v)
for v in values):
return np.asarray(values, dtype=float)
return np.array([_as_cell(v) if isinstance(v, (list, tuple)) else v
for v in values], dtype=object)
return np.asarray(values)
def write_mat(name: str, data: dict[str, Any]) -> None:
"""写 .mat;字符串字段转 object 数组,MATLAB 侧按 cell 读取。"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
def wrap(value: Any, key: str) -> Any: # noqa: ARG001
if isinstance(value, (str, list, tuple)):
return _as_cell(value)
return value
payload = {key: wrap(value, key) for key, value in data.items()}
savemat(DATA_DIR / f"{name}.mat", payload, do_compression=True,
long_field_names=True)
def prepare(only: Sequence[str] | None) -> dict[str, list[dict[str, Any]]]:
"""写全部 .mat 数据,返回每张图的数据来源。"""
want = set(only) if only else None
runs = discover_runs()
q3 = pick_run(runs, "q3", 10) if runs else None
q4 = (pick_case(runs, Q4_REPLAY_CASE) if runs else None) or \
(pick_run(runs, "q4", 12) if runs else None)
sources: dict[str, list[dict[str, Any]]] = {}
def need(stem: str) -> bool:
return want is None or stem in want
build: list[tuple[str, str, Callable[[], dict[str, Any]]]] = [
("flow-overall-architecture", "flow_overall", data_flow_overall),
("steps-state-machine", "steps_state_machine", data_steps_state_machine),
("timing-attachment-example-105-111-194-199", "timing_attachment",
data_timing_attachment),
("geo-q1-diameter-circle-counterexample", "geo_q1_counterexample",
data_geo_q1_counterexample),
("geo-q2-guaranteed-reception-disk", "geo_q2", data_geo_q2),
("geo-q3-seven-point-coverage", "geo_q3", data_geo_q3),
("geo-q4-minimum-cover-31-points", "geo_q4", data_geo_q4),
]
for stem, name, maker in build:
if not need(stem):
continue
write_mat(name, maker())
sources[stem] = [source(p) for p in SOURCES.get(stem, [])]
if need("geo-q1-convex-intersection-zoom"):
data, src = data_geo_q1_meetup()
write_mat("geo_q1_meetup", data)
sources["geo-q1-convex-intersection-zoom"] = [source(src)]
if q3:
if need("timing-measured-q3-10-sources"):
write_mat("timing_measured", data_timing_measured(q3))
sources["timing-measured-q3-10-sources"] = [
source(q3["source"], case_code=q3["case_code"], module=q3["module"])]
if need("replay-webui-q3-omnidirectional"):
write_mat("replay_q3", data_replay(q3, directional=False))
sources["replay-webui-q3-omnidirectional"] = [
source(q3["source"], case_code=q3["case_code"])]
if q4 and need("replay-webui-q4-directional"):
write_mat("replay_q4", data_replay(q4, directional=True))
sources["replay-webui-q4-directional"] = [
source(q4["source"], case_code=q4["case_code"])]
return sources
def run_matlab(only: Sequence[str] | None, matlab_bin: Path) -> None:
"""逐张调用 MATLAB 出图。
本机实测:同一 MATLAB 批处理会话里渲染第二张图时图形子系统会崩溃,
因此每张图单独起一个会话(每次约数秒),并显式 exit。
"""
if not matlab_bin.is_file():
raise SystemExit(f"未找到 MATLAB:{matlab_bin}")
want = set(only) if only else None
log = MATLAB_DIR / "matlab_run.log"
lines: list[str] = []
for stem, fn, name in FIGURES:
if want and stem not in want:
continue
call = f"{fn}('{name}')" if fn == "fig_replay" else f"{fn}()"
script = (f"cd('{MATLAB_DIR}'); addpath('{MATLAB_DIR}'); "
f"{call}; exit")
proc = subprocess.run([str(matlab_bin), "-batch", script],
capture_output=True, text=True, check=False)
ok = (OUT_DIR / f"{stem}.png").is_file()
lines.append(f"{'OK ' if ok else 'FAIL'} {stem}")
if not ok:
tail = [ln for ln in (proc.stdout + proc.stderr).splitlines()
if ln.strip() and "Keyboard" not in ln and "TISFile" not in ln]
lines.extend(f" {ln}" for ln in tail[-6:])
log.write_text("\n".join(lines) + "\n", encoding="utf-8")
def finish(only: Sequence[str] | None, sources: dict[str, list[dict[str, Any]]]) -> None:
"""删掉历史遗留的 SVG,写 manifest。"""
for stale in OUT_DIR.glob("*.svg"):
stale.unlink()
want = set(only) if only else None
entries = []
for stem, fn, _name in FIGURES:
if want and stem not in want:
continue
png = OUT_DIR / f"{stem}.png"
entry = {"stem": stem, "png": str(png.relative_to(ROOT)),
"matlab": f"scripts/matlab/{fn}.m",
"sources": sources.get(stem, [])}
if not png.is_file():
entry["missing"] = True
entries.append(entry)
manifest = {
"generator": "scripts/build_figure_data.py(数据)+ scripts/matlab/*.m(MATLAB 出图)",
"command": "uv run python scripts/build_figure_data.py"
+ (f" --only {','.join(only)}" if only else ""),
"manual": "MATLAB 中 cd scripts/matlab 后执行 run_all_figures",
"figures": entries,
}
(OUT_DIR / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--only", default="", help="逗号分隔的图名子集(见 --list)")
parser.add_argument("--prepare", action="store_true", help="只写 .mat 数据,不调 MATLAB")
parser.add_argument("--list", action="store_true", help="列出全部图名")
parser.add_argument("--matlab", default=str(MATLAB_BIN), help="matlab 可执行文件路径")
args = parser.parse_args()
names = [stem for stem, _fn, _name in FIGURES]
if args.list:
print("\n".join(names))
return 0
only = [s.strip() for s in args.only.split(",") if s.strip()]
unknown = [s for s in only if s not in set(names)]
if unknown:
parser.error(f"未知图名 {unknown}")
sources = prepare(only or None)
if args.prepare:
print(f"[fig] 已写入 {len(list(DATA_DIR.glob('*.mat')))} 个 .mat → "
f"{DATA_DIR.relative_to(ROOT)}")
return 0
targets = only or names
for stem in targets: # 先清掉旧文件:只认本次生成的
(OUT_DIR / f"{stem}.png").unlink(missing_ok=True)
run_matlab(only or None, Path(args.matlab))
finish(only or None, sources)
missing = []
for stem in targets:
if (OUT_DIR / f"{stem}.png").is_file():
print(f"[fig] OK {stem}")
else:
missing.append(stem)
print(f"[fig] 缺失 {stem}")
print(f"[fig] manifest → {(OUT_DIR / 'manifest.json').relative_to(ROOT)}")
if missing:
print(f"[fig] {len(missing)} 张未生成:{', '.join(missing)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
function fig_flow_overall()
%FIG_FLOW_OVERALL 四层主线:题面抽象(L0)、在线三层(L1)、四问主线(L2)、三条闭环(L3)。
% 仅保留分层框图与层标签,图内不放任何说明性文字。
S = fig_style();
D = load_data(S, 'flow_overall');
H = geom_helpers();
fig = figure('Color', 'w', 'Visible', 'off');
ax = axes(fig, 'Position', [0.02 0.06 0.96 0.90], ...
'XLim', [0 100], 'YLim', [0 100], 'Visible', 'off');
hold(ax, 'on');
n = numel(D.tags);
x_tag = 7; % 层标签中心
x_lo = 15; x_hi = 99; % 框体水平范围
box_gap = 2.2; % 框间距(放箭头)
pad = 1.4; % 框内左右留白
band_h = 14; % 层高
band_gap = 6.5;
y_top = 96;
% 统一字号:逐行按量得的文本宽度试算,保证一排框加间距放得下
fs = ax.FontSize;
while fs > 8
need_max = 0;
for b = 1:n
labels = D.labels{b};
m = numel(labels);
need = sum(cellfun(@(s) H.text_width(ax, s, fs), labels)) ...
+ 2 * pad * m + box_gap * (m - 1);
need_max = max(need_max, need);
end
if need_max <= x_hi - x_lo
break;
end
fs = fs - 0.5;
end
for b = 1:n
labels = D.labels{b};
m = numel(labels);
tw = cellfun(@(s) H.text_width(ax, s, fs), labels);
avail = (x_hi - x_lo) - box_gap * (m - 1);
w = (tw + 2 * pad) * avail / sum(tw + 2 * pad);
y = y_top - band_h - (b - 1) * (band_h + band_gap);
color = pick_color(S, D.colors{b});
text(ax, x_tag, y + band_h / 2, D.tags{b}, 'FontSize', fs + 1, ...
'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle');
x = x_lo;
for i = 1:m
rectangle(ax, 'Position', [x, y, w(i), band_h], 'FaceColor', 'w', ...
'EdgeColor', color, 'LineWidth', 1.1);
text(ax, x + w(i) / 2, y + band_h / 2, labels{i}, 'FontSize', fs, ...
'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ...
'Interpreter', 'none');
if i < m
H.arrow(ax, [x + w(i) + 0.25, y + band_h / 2], ...
[x + w(i) + box_gap - 0.25, y + band_h / 2], color);
end
x = x + w(i) + box_gap;
end
end
hold(ax, 'off');
fig_save(fig, S, 'flow-overall-architecture.png');
end
@@ -0,0 +1,52 @@
function fig_geo_q1_counterexample()
%FIG_GEO_Q1_COUNTEREXAMPLE 问题一:等边三角形布站时直径圆不覆盖定位区域的反例。
S = fig_style();
D = load_data(S, 'geo_q1_counterexample');
H = geom_helpers();
fig = figure('Color', 'w', 'Visible', 'off');
t = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact');
% 左:角域交会总览
ax1 = nexttile(t);
hold(ax1, 'on');
hw = gobjects(0);
for i = 1:size(D.wedges, 1)
hw = H.wedge(ax1, squeeze(D.wedges(i, :, :)), pick_color(S, 'primary'), 0.25);
end
hr = plot(ax1, D.zoom_center(1), D.zoom_center(2), 's', 'MarkerSize', 6, ...
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
plot(ax1, D.stations(:, 1), D.stations(:, 2), 'o', 'MarkerSize', 6, ...
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
H.label_points(ax1, D.stations, D.station_labels, pick_color(S, 'accent'));
hold(ax1, 'off');
axis(ax1, 'equal');
xlim(ax1, D.view_center(1) + [-D.half_overview, D.half_overview]);
ylim(ax1, D.view_center(2) + [-D.half_overview, D.half_overview]);
xlabel(ax1, 'x / m');
ylabel(ax1, 'y / m');
legend(ax1, [hw(1), hr], D.legend1, 'Location', 'southoutside', ...
'Orientation', 'horizontal');
% 右:定位区域放大
ax2 = nexttile(t);
hold(ax2, 'on');
for i = 1:size(D.zoom_wedges, 1)
H.wedge(ax2, squeeze(D.zoom_wedges(i, :, :)), pick_color(S, 'primary'), 0.25);
end
H.poly(ax2, D.zoom_region, pick_color(S, 'accent'), 1, 'none', 0.5);
hd = H.circle(ax2, D.dc_center, D.dc_radius, pick_color(S, 'violet'), 1.2, '--');
hm = H.circle(ax2, D.mec_center, D.mec_radius, pick_color(S, 'success'), 1.4, '-');
H.marker(ax2, D.dc_center, pick_color(S, 'violet'), 5, true);
H.marker(ax2, D.mec_center, pick_color(S, 'success'), 5, true);
hold(ax2, 'off');
axis(ax2, 'equal');
xlim(ax2, D.zoom_center(1) + [-D.zoom_half, D.zoom_half]);
ylim(ax2, D.zoom_center(2) + [-D.zoom_half, D.zoom_half]);
xlabel(ax2, 'x / m');
ylabel(ax2, 'y / m');
legend(ax2, [hd, hm], D.legend2, 'Location', 'southoutside', ...
'Orientation', 'horizontal');
fig_save(fig, S, 'geo-q1-diameter-circle-counterexample.png');
end
+52
View File
@@ -0,0 +1,52 @@
function fig_geo_q1_meetup()
%FIG_GEO_Q1_MEETUP 问题一:角域交会总览与定位区域放大(直径圆、最小包围圆)。
S = fig_style();
D = load_data(S, 'geo_q1_meetup');
H = geom_helpers();
fig = figure('Color', 'w', 'Visible', 'off');
t = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact');
% 左:角域交会总览(交会区域在总览里只是一个点,放大后见右图)
ax1 = nexttile(t);
hold(ax1, 'on');
hw = gobjects(0);
for i = 1:size(D.wedges, 1)
hw = H.wedge(ax1, squeeze(D.wedges(i, :, :)), pick_color(S, 'primary'), 0.25);
end
hr = plot(ax1, D.zoom_center(1), D.zoom_center(2), 's', 'MarkerSize', 6, ...
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
plot(ax1, D.stations(:, 1), D.stations(:, 2), 'o', 'MarkerSize', 6, ...
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
H.label_points(ax1, D.stations, D.station_labels, pick_color(S, 'accent'));
hold(ax1, 'off');
axis(ax1, 'equal');
xlim(ax1, D.view_center(1) + [-D.half_overview, D.half_overview]);
ylim(ax1, D.view_center(2) + [-D.half_overview, D.half_overview]);
xlabel(ax1, 'x / m');
ylabel(ax1, 'y / m');
legend(ax1, [hw(1), hr], D.legend1, 'Location', 'southoutside', ...
'Orientation', 'horizontal');
% 右:定位区域放大
ax2 = nexttile(t);
hold(ax2, 'on');
for i = 1:size(D.zoom_wedges, 1)
H.wedge(ax2, squeeze(D.zoom_wedges(i, :, :)), pick_color(S, 'primary'), 0.25);
end
H.poly(ax2, D.zoom_region, pick_color(S, 'accent'), 1, 'none', 0.5);
hd = H.circle(ax2, D.dc_center, D.dc_radius, pick_color(S, 'violet'), 1.2, '--');
hm = H.circle(ax2, D.mec_center, D.mec_radius, pick_color(S, 'success'), 1.4, '-');
H.marker(ax2, D.dc_center, pick_color(S, 'violet'), 5, true);
H.marker(ax2, D.mec_center, pick_color(S, 'success'), 5, true);
hold(ax2, 'off');
axis(ax2, 'equal');
xlim(ax2, D.zoom_center(1) + [-D.zoom_half, D.zoom_half]);
ylim(ax2, D.zoom_center(2) + [-D.zoom_half, D.zoom_half]);
xlabel(ax2, 'x / m');
ylabel(ax2, 'y / m');
legend(ax2, [hd, hm], D.legend2, 'Location', 'southoutside', ...
'Orientation', 'horizontal');
fig_save(fig, S, 'geo-q1-convex-intersection-zoom.png');
end
+55
View File
@@ -0,0 +1,55 @@
function fig_geo_q2_safe_disk()
%FIG_GEO_Q2_SAFE_DISK 问题二:截断角域外接圆与保证接收的安全候选盘。
S = fig_style();
D = load_data(S, 'geo_q2');
H = geom_helpers();
fig = figure('Color', 'w', 'Visible', 'off');
t = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact');
% 左:真实几何(Ω 内的角域与两个候选圆)
ax1 = nexttile(t);
hold(ax1, 'on');
H.circle(ax1, [0 0], D.omega_r, pick_color(S, 'primary'), 1.2, '-');
hw = H.wedge(ax1, D.wedge, pick_color(S, 'accent'), 0.25);
hd = H.circle(ax1, D.c, D.a, pick_color(S, 'violet'), 1.2, '--');
hs = H.disk(ax1, D.c, D.safe, pick_color(S, 'success'), 0.30, '-');
H.marker(ax1, [0 0], pick_color(S, 'accent'), 6, true);
H.marker(ax1, D.c, pick_color(S, 'violet'), 5, true);
H.label_points(ax1, [0 0], {'s1'}, pick_color(S, 'accent'));
H.label_points(ax1, D.c, {'c'}, pick_color(S, 'violet'));
hold(ax1, 'off');
axis(ax1, 'equal');
xlim(ax1, [-D.half, D.half]);
ylim(ax1, [-D.half, D.half]);
xlabel(ax1, 'x / m');
ylabel(ax1, 'y / m');
legend(ax1, [hw, hd, hs], D.legend, 'Location', 'southoutside', ...
'Orientation', 'horizontal');
% 右:半径分解 a + (1000 − a) = 1000 m
ax2 = nexttile(t);
hold(ax2, 'on');
H.circle(ax2, [0 0], D.a, pick_color(S, 'violet'), 1.2, '--');
H.disk(ax2, [0 0], D.safe, pick_color(S, 'success'), 0.30, '-');
H.marker(ax2, [0 0], 'k', 5, true);
ang = deg2rad(35);
p_out = D.a * [cos(ang), sin(ang)];
p_in = D.safe * [cos(-ang), sin(-ang)];
H.arrow(ax2, [0 0], p_out, pick_color(S, 'violet'));
H.arrow(ax2, [0 0], p_in, pick_color(S, 'success'));
text(ax2, p_out(1) * 1.06, p_out(2) * 1.12, sprintf('a = %.1f m', D.a), ...
'Color', pick_color(S, 'violet'), 'Interpreter', 'none');
text(ax2, p_in(1) * 1.04, p_in(2) * 1.30, sprintf('1000 − a = %.1f m', D.safe), ...
'Color', pick_color(S, 'success'), 'HorizontalAlignment', 'right', ...
'Interpreter', 'none');
text(ax2, -D.safe * 0.45, 0, 'c', 'Color', 'k', ...
'HorizontalAlignment', 'right', 'VerticalAlignment', 'middle');
hold(ax2, 'off');
axis(ax2, 'equal');
xlim(ax2, [-D.a * 1.15, D.a * 1.75]);
ylim(ax2, [-D.a * 1.05, D.a * 1.05]);
axis(ax2, 'off');
fig_save(fig, S, 'geo-q2-guaranteed-reception-disk.png');
end
+29
View File
@@ -0,0 +1,29 @@
function fig_geo_q3_coverage()
%FIG_GEO_Q3_COVERAGE 问题三:七个 1000 m 接收盘覆盖区域 Ω。
S = fig_style();
D = load_data(S, 'geo_q3');
H = geom_helpers();
fig = figure('Color', 'w', 'Visible', 'off');
ax = axes(fig);
hold(ax, 'on');
hd = gobjects(0);
for i = 1:size(D.stations, 1)
hd = H.disk(ax, D.stations(i, :), D.r_eff, pick_color(S, 'success'), 0.18, '-');
end
hw = H.circle(ax, [0 0], D.omega_r, pick_color(S, 'primary'), 1.2, '-');
hs = plot(ax, D.stations(:, 1), D.stations(:, 2), 'o', 'MarkerSize', 6, ...
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
hx = H.marker(ax, D.worst, 'k', 8, false);
hold(ax, 'off');
axis(ax, 'equal');
xlim(ax, [-D.half, D.half]);
ylim(ax, [-D.half, D.half]);
xlabel(ax, 'x / m');
ylabel(ax, 'y / m');
legend(ax, [hw, hd(1), hs, hx], D.legend, 'Location', 'southoutside', ...
'Orientation', 'horizontal');
fig_save(fig, S, 'geo-q3-seven-point-coverage.png');
end
+44
View File
@@ -0,0 +1,44 @@
function fig_geo_q4_mincover()
%FIG_GEO_Q4_MINCOVER 问题四:31 点最小命中集与前向半盘覆盖语义。
S = fig_style();
D = load_data(S, 'geo_q4');
H = geom_helpers();
fig = figure('Color', 'w', 'Visible', 'off');
t = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact');
% 左:Ω 内的 31 个覆盖点
ax1 = nexttile(t);
hold(ax1, 'on');
hw = H.circle(ax1, [0 0], D.omega_r, pick_color(S, 'primary'), 1.2, '-');
hp = plot(ax1, D.points(:, 1), D.points(:, 2), 'o', 'MarkerSize', 5, ...
'MarkerFaceColor', pick_color(S, 'accent'), 'MarkerEdgeColor', 'none');
hold(ax1, 'off');
axis(ax1, 'equal');
xlim(ax1, [-D.half, D.half]);
ylim(ax1, [-D.half, D.half]);
xlabel(ax1, 'x / m');
ylabel(ax1, 'y / m');
legend(ax1, [hw, hp], D.legend, 'Location', 'southoutside', ...
'Orientation', 'horizontal');
% 右:前向半盘(朝向未知,±90°)
ax2 = nexttile(t);
hold(ax2, 'on');
H.poly(ax2, D.half_disk, pick_color(S, 'accent'), 0.30, 'none', 0.5);
H.circle(ax2, [0 0.5], 0.85, pick_color(S, 'muted'), 0.9, '-');
plot(ax2, [0 0], [0.5 - 0.85, 0.5 + 0.85], '--', ...
'Color', pick_color(S, 'accent'), 'LineWidth', 1.0);
H.arrow(ax2, [0 0.5], [0.85 0.5], pick_color(S, 'accent'));
text(ax2, 0.425, 0.30, D.radius_label, 'HorizontalAlignment', 'center', ...
'Interpreter', 'none');
text(ax2, 0.0, 1.55, D.heading_label, 'HorizontalAlignment', 'center', ...
'Interpreter', 'none');
hold(ax2, 'off');
axis(ax2, 'equal');
xlim(ax2, [-1.15 1.35]);
ylim(ax2, [-0.55 1.75]);
axis(ax2, 'off');
fig_save(fig, S, 'geo-q4-minimum-cover-31-points.png');
end
+109
View File
@@ -0,0 +1,109 @@
function fig_replay(name)
%FIG_REPLAY 回放画布(点运动图):世界视图 + 事件流水 + 虚拟时间轴。
% name: 'replay_q3' 或 'replay_q4',对应 docs/figures/.data/<name>.mat。
% 本图是唯一保留自定义画布版式的图:三个区互不重叠,图例置于世界视图下方。
if nargin < 1
name = 'replay_q3';
end
S = fig_style();
D = load_data(S, name);
H = geom_helpers();
fig = figure('Color', 'w', 'Visible', 'off', 'Position', [100 100 1040 660]);
c_path = [0.35 0.35 0.35];
c_ray = pick_color(S, 'primary');
c_solved = pick_color(S, 'success');
c_unsolv = pick_color(S, 'accent');
c_head = pick_color(S, 'violet');
% ---------------- 世界视图 ----------------
ax = axes(fig, 'Position', [0.075 0.40 0.49 0.56]);
hold(ax, 'on');
H.circle(ax, [0 0], D.omega_r, pick_color(S, 'primary'), 1.2, '-');
h_path = plot(ax, D.path(:, 1), D.path(:, 2), 'Color', c_path, 'LineWidth', 0.9);
h_ray = plot(ax, nan, nan, 'Color', c_ray, 'LineWidth', 0.8);
for i = 1:size(D.rays, 1)
p0 = D.rays(i, 1:2);
p1 = p0 + D.rays(i, 3:4);
plot(ax, [p0(1) p1(1)], [p0(2) p1(2)], 'Color', c_ray, 'LineWidth', 0.8);
end
h_disk = gobjects(0);
for i = 1:size(D.half_disks, 1)
disk = squeeze(D.half_disks(i, :, :));
h_disk(end + 1) = patch(ax, 'XData', disk(:, 1), 'YData', disk(:, 2), ...
'FaceColor', c_head, 'FaceAlpha', 0.10, ...
'EdgeColor', c_head, 'LineWidth', 0.8); %#ok<AGROW>
end
for i = 1:size(D.headings, 1)
p0 = D.headings(i, 1:2);
p1 = p0 + D.headings(i, 3:4);
H.arrow(ax, p0, p1, c_head);
end
solved = D.truth_solved > 0.5;
h_solved = plot(ax, D.truth(solved, 1), D.truth(solved, 2), 'o', ...
'MarkerSize', 6, 'MarkerFaceColor', c_solved, 'MarkerEdgeColor', 'none');
h_unsolv = plot(ax, D.truth(~solved, 1), D.truth(~solved, 2), 'o', ...
'MarkerSize', 6, 'MarkerFaceColor', c_unsolv, 'MarkerEdgeColor', 'none');
label_color = repmat(c_unsolv, size(D.truth, 1), 1);
label_color(solved, :) = repmat(c_solved, nnz(solved), 1);
H.label_points(ax, D.truth, D.truth_labels, label_color);
plot(ax, D.clears(D.clears(:, 3) > 0.5, 1), D.clears(D.clears(:, 3) > 0.5, 2), ...
'x', 'MarkerSize', 7, 'LineWidth', 1.4, 'Color', c_solved);
plot(ax, D.clears(D.clears(:, 3) <= 0.5, 1), D.clears(D.clears(:, 3) <= 0.5, 2), ...
'o', 'MarkerSize', 6, 'MarkerEdgeColor', 'k', 'MarkerFaceColor', 'none');
hold(ax, 'off');
axis(ax, 'equal');
xlim(ax, [-D.half, D.half]);
ylim(ax, [-D.half, D.half]);
xlabel(ax, 'x / m');
ylabel(ax, 'y / m');
hleg = [h_path, h_ray];
if ~isempty(h_disk)
hleg(end + 1) = h_disk(1);
end
hleg = [hleg, h_solved, h_unsolv];
legend(ax, hleg, D.legend, 'Location', 'southoutside', 'Orientation', 'horizontal');
% ---------------- 事件流水 ----------------
ex = axes(fig, 'Position', [0.625 0.40 0.345 0.56], ...
'XLim', [0 1], 'YLim', [0 1], 'Visible', 'off');
hold(ex, 'on');
cols = [0.00 0.34 1.00];
align = {'left', 'left', 'right'};
head = {'动作', '参数', 't / s'};
for j = 1:3
text(ex, cols(j), 0.97, head{j}, 'FontWeight', 'bold', ...
'HorizontalAlignment', align{j});
end
for i = 1:size(D.events, 1)
y = 0.90 - (i - 1) * 0.088;
for j = 1:3
text(ex, cols(j), y, D.events{i, j}, 'HorizontalAlignment', align{j}, ...
'Interpreter', 'none');
end
end
hold(ex, 'off');
% ---------------- 虚拟时间轴 ----------------
tx = axes(fig, 'Position', [0.075 0.155 0.90 0.125]);
hold(tx, 'on');
rectangle(tx, 'Position', [0 0.34 D.total_s 0.32], ...
'FaceColor', pick_color(S, 'primary'), 'FaceAlpha', 0.12, ...
'EdgeColor', 'none');
for i = 1:size(D.marks, 1)
t = D.marks(i, 1);
if D.marks(i, 3) > 0.5
plot(tx, [t t], [0.05 0.95], 'Color', c_solved, 'LineWidth', 1.0);
elseif D.marks(i, 2) > 0.5
plot(tx, [t t], [0.22 0.78], 'Color', c_unsolv, 'LineWidth', 0.8);
end
end
hold(tx, 'off');
xlim(tx, [0 D.total_s]);
ylim(tx, [0 1]);
set(tx, 'YTick', []);
xlabel(tx, '虚拟时间 t / s');
fig_save(fig, S, D.out_png{1});
end
+11
View File
@@ -0,0 +1,11 @@
function fig_save(fig, S, name)
%FIG_SAVE 导出白底 PNG(300 dpi)并关闭图形窗口。
% 导出前关掉坐标轴工具条,避免交互按钮被画进图片。
axs = findall(fig, 'Type', 'axes');
for k = 1:numel(axs)
axs(k).Toolbar.Visible = 'off';
end
exportgraphics(fig, fullfile(S.out_dir, name), 'Resolution', S.dpi, ...
'BackgroundColor', 'white');
close(fig);
end
+34
View File
@@ -0,0 +1,34 @@
function fig_steps_state_machine()
%FIG_STEPS_STATE_MACHINE 会话状态机:IDLE → 倒计时 → OPEN → CLOSED。
% 只画状态框与迁移箭头,图内不放任何说明性文字。
S = fig_style();
D = load_data(S, 'steps_state_machine');
H = geom_helpers();
fig = figure('Color', 'w', 'Visible', 'off', 'Position', [100 100 900 240]);
ax = axes(fig, 'Position', [0.02 0.10 0.96 0.80], ...
'XLim', [0 100], 'YLim', [0 100], 'Visible', 'off');
hold(ax, 'on');
n = numel(D.labels);
w = 21;
gap = 4.0;
x0 = (100 - (n * w + (n - 1) * gap)) / 2;
y0 = 25;
h = 50;
for i = 1:n
x = x0 + (i - 1) * (w + gap);
color = pick_color(S, D.edges{i});
rectangle(ax, 'Position', [x, y0, w, h], 'FaceColor', 'w', ...
'EdgeColor', color, 'LineWidth', 1.2);
text(ax, x + w / 2, y0 + h / 2, D.labels{i}, ...
'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ...
'Interpreter', 'none');
if i < n
H.arrow(ax, [x + w + 0.5, y0 + h / 2], ...
[x + w + gap - 0.5, y0 + h / 2], color);
end
end
hold(ax, 'off');
fig_save(fig, S, 'steps-state-machine.png');
end
+11
View File
@@ -0,0 +1,11 @@
function S = fig_style()
%FIG_STYLE 出图环境:目录、导出参数与 MATLAB 默认配色。
% 除 fig_replay(回放画布,点运动图)外,各图一律使用 MATLAB 默认图形样式:
% 默认配色、默认字体、默认坐标轴与图例;本函数不设置任何自定义样式。
root = fileparts(fileparts(fileparts(mfilename('fullpath'))));
S.data_dir = fullfile(root, 'docs', 'figures', '.data');
S.out_dir = fullfile(root, 'docs', 'figures');
S.dpi = 300; % 论文插图导出分辨率
S.c = get(groot, 'defaultAxesColorOrder'); % 默认配色(7×3)
end
+29
View File
@@ -0,0 +1,29 @@
function fig_timing_attachment()
%FIG_TIMING_ATTACHMENT 附件计时示例:逐动作的耗时构成(堆叠条形)与单步合计。
S = fig_style();
D = load_data(S, 'timing_attachment');
fig = figure('Color', 'w', 'Visible', 'off');
ax = axes(fig);
n = numel(D.labels);
Y = [D.move(:), D.switch_s(:), D.action(:)];
y = (n:-1:1)'; % 动作按先后从上到下排列
barh(ax, y, Y, 'stacked');
total = sum(Y, 2);
xlim(ax, [0, max(total) * 1.18]);
hold(ax, 'on');
for i = 1:n
if total(i) > 0
text(ax, total(i) + max(total) * 0.02, y(i), ...
sprintf('%.0f s', total(i)), 'VerticalAlignment', 'middle', ...
'Interpreter', 'none');
end
end
hold(ax, 'off');
set(ax, 'YTick', 1:n, 'YTickLabel', flipud(D.labels(:)), ...
'TickLabelInterpreter', 'none');
xlabel(ax, D.xlabel);
legend(ax, D.legend);
fig_save(fig, S, 'timing-attachment-example-105-111-194-199.png');
end
+17
View File
@@ -0,0 +1,17 @@
function fig_timing_measured()
%FIG_TIMING_MEASURED 实测一局:累计虚拟时间按动作类型的堆叠构成。
S = fig_style();
D = load_data(S, 'timing_measured');
fig = figure('Color', 'w', 'Visible', 'off');
ax = axes(fig);
seq = D.seq(:);
bands = [D.cum(:, 1), diff(D.cum, 1, 2)]; % area 对矩阵按列堆叠,这里给每层厚度
h = area(ax, seq, bands, 'FaceAlpha', 1);
xlim(ax, [seq(1), seq(end)]);
xlabel(ax, D.xlabel);
ylabel(ax, D.ylabel);
legend(ax, h, D.legend, 'Location', 'northwest');
fig_save(fig, S, 'timing-measured-q3-10-sources.png');
end
+194
View File
@@ -0,0 +1,194 @@
function H = geom_helpers()
%GEOM_HELPERS 几何示意图共用的绘制函数。
% 只使用 MATLAB 默认图形对象与默认配色,不做自定义样式。
H.circle = @draw_circle;
H.wedge = @draw_wedge;
H.disk = @draw_disk;
H.poly = @draw_poly;
H.marker = @draw_marker;
H.arrow = @draw_arrow;
H.label_points = @label_points;
H.text_width = @text_width;
end
% ---------------------------------------------------------------------------
% 基本图元
% ---------------------------------------------------------------------------
function h = draw_circle(ax, center, radius, color, lw, style)
t = linspace(0, 2 * pi, 360);
h = plot(ax, center(1) + radius * cos(t), center(2) + radius * sin(t), ...
'Color', color, 'LineWidth', lw, 'LineStyle', style);
end
function h = draw_wedge(ax, poly, color, alpha)
h = patch(ax, 'XData', poly(:, 1), 'YData', poly(:, 2), 'FaceColor', color, ...
'FaceAlpha', alpha, 'EdgeColor', 'none');
end
function h = draw_disk(ax, center, radius, color, alpha, edge_style)
t = linspace(0, 2 * pi, 360);
h = patch(ax, 'XData', center(1) + radius * cos(t), ...
'YData', center(2) + radius * sin(t), ...
'FaceColor', color, 'FaceAlpha', alpha, 'EdgeColor', color, ...
'LineWidth', 0.6, 'LineStyle', edge_style);
end
function h = draw_poly(ax, poly, face, alpha, edge, lw)
h = patch(ax, 'XData', poly(:, 1), 'YData', poly(:, 2), 'FaceColor', face, ...
'FaceAlpha', alpha, 'EdgeColor', edge, 'LineWidth', lw);
end
function h = draw_marker(ax, p, color, size, filled)
if filled
h = plot(ax, p(1), p(2), 'o', 'MarkerSize', size, ...
'MarkerFaceColor', color, 'MarkerEdgeColor', 'none');
else
h = plot(ax, p(1), p(2), 'o', 'MarkerSize', size, ...
'MarkerFaceColor', 'none', 'MarkerEdgeColor', color, ...
'LineWidth', 0.9);
end
end
function h = draw_arrow(ax, p0, p1, color)
%DRAW_ARROW 数据坐标箭头:直线 + 实心三角箭头。
% 箭头在"像素空间"里构造后换算回数据坐标,因此与坐标轴比例无关,
% 也不依赖绘制时的 axes 位置(图例把坐标区挤小后箭头依然对准数据点)。
h = plot(ax, [p0(1) p1(1)], [p0(2) p1(2)], 'Color', color, 'LineWidth', 0.9);
d = [p1(1) - p0(1), p1(2) - p0(2)];
[sx, sy] = px_scale(ax); % 每像素对应的数据单位
v = [d(1) / sx, d(2) / sy]; % 像素空间向量
len = hypot(v(1), v(2));
if len < 1e-9
return;
end
u = v / len;
n = [-u(2), u(1)];
head = min(len * 0.6, 10); % 头长(像素)
base = v - u * head;
tri = [v; base + n * head * 0.42; base - n * head * 0.42] .* [sx, sy];
patch(ax, 'XData', p0(1) + tri(:, 1), 'YData', p0(2) + tri(:, 2), ...
'FaceColor', color, 'EdgeColor', 'none');
end
function [sx, sy] = px_scale(ax)
pos = ax.Position;
fpos = ancestor(ax, 'figure').Position;
sx = diff(ax.XLim) / (pos(3) * fpos(3));
sy = diff(ax.YLim) / (pos(4) * fpos(4));
end
% ---------------------------------------------------------------------------
% 标签自动避让
% ---------------------------------------------------------------------------
function label_points(ax, pts, labels, color)
%LABEL_POINTS 在散点旁放置标签:按候选方位打分选出与已放标签、标记点都不重叠的位置。
% color 可以是 1×3(所有标签同色)或 n×3(逐点配色)。
if isempty(pts)
return;
end
if size(color, 1) == 1
color = repmat(color, numel(labels), 1);
end
xl = ax.XLim;
yl = ax.YLim;
gap = pixel_gap(ax, 5); % 标签与标记点之间的固定像素间距
mbox = marker_size(ax) * [-0.5 -0.5 1 1];
placed = zeros(0, 4);
for i = 1:numel(labels)
if pts(i, 1) < xl(1) || pts(i, 1) > xl(2) || ...
pts(i, 2) < yl(1) || pts(i, 2) > yl(2)
continue; % 视野外的点不标注
end
sz = text_size(ax, labels{i});
cand = candidates(pts(i, :), sz, gap);
best_pen = inf;
best = cand(1, :);
for k = 1:size(cand, 1)
box = [cand(k, 1) - sz(1) / 2 - gap(1), cand(k, 2) - sz(2) / 2 - gap(2), ...
sz(1) + 2 * gap(1), sz(2) + 2 * gap(2)];
pen = penalty(box, placed, pts, mbox, xl, yl);
if pen < best_pen
best_pen = pen;
best = cand(k, :);
end
end
text(ax, best(1), best(2), labels{i}, 'Color', color(i, :), ...
'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle');
placed(end + 1, :) = [best(1) - sz(1) / 2 - gap(1), ...
best(2) - sz(2) / 2 - gap(2), ...
sz(1) + 2 * gap(1), sz(2) + 2 * gap(2)]; %#ok<AGROW>
end
end
function cand = candidates(p, sz, gap)
%CANDIDATES 标记点周围的候选标签中心:先近后远,四正方向 + 四斜角。
dx = gap(1) + sz(1) / 2;
dy = gap(2) + sz(2) / 2;
cand = [p(1) + dx, p(2); p(1) - dx, p(2)
p(1), p(2) + dy; p(1), p(2) - dy
p(1) + dx, p(2) + dy; p(1) - dx, p(2) + dy
p(1) + dx, p(2) - dy; p(1) - dx, p(2) - dy
p(1) + 2.2 * dx, p(2); p(1) - 2.2 * dx, p(2)
p(1), p(2) + 2.2 * dy; p(1), p(2) - 2.2 * dy
p(1) + 1.9 * dx, p(2) + 1.9 * dy; p(1) - 1.9 * dx, p(2) + 1.9 * dy
p(1) + 1.9 * dx, p(2) - 1.9 * dy; p(1) - 1.9 * dx, p(2) - 1.9 * dy
p(1) + 3.4 * dx, p(2); p(1) - 3.4 * dx, p(2)
p(1), p(2) + 3.4 * dy; p(1), p(2) - 3.4 * dy];
end
function g = pixel_gap(ax, npix)
pos = ax.Position;
fpos = ancestor(ax, 'figure').Position;
g = [npix * diff(ax.XLim) / (pos(3) * fpos(3)), ...
npix * diff(ax.YLim) / (pos(4) * fpos(4))];
end
function pen = penalty(box, placed, pts, mbox, xl, yl)
pen = 0;
for j = 1:size(placed, 1) % 标签之间重叠代价最高
pen = pen + 12 * overlap(box, placed(j, :));
end
for j = 1:size(pts, 1) % 其次避免压住标记点
mb = [pts(j, 1) + mbox(1), pts(j, 2) + mbox(2), mbox(3), mbox(4)];
pen = pen + 3 * overlap(box, mb);
end
dx = max(0, xl(1) - box(1)) + max(0, box(1) + box(3) - xl(2));
dy = max(0, yl(1) - box(2)) + max(0, box(2) + box(4) - yl(2));
pen = pen + 1e3 * (dx * box(4) + dy * box(3));
end
function a = overlap(b1, b2)
w = min(b1(1) + b1(3), b2(1) + b2(3)) - max(b1(1), b2(1));
h = min(b1(2) + b1(4), b2(2) + b2(4)) - max(b1(2), b2(2));
a = max(0, w) * max(0, h);
end
function sz = text_size(ax, str)
%TEXT_SIZE 文本在数据坐标下的包围盒尺寸(用临时 text 对象的 Extent 量取)。
h = text(ax, 0, 0, str, 'Units', 'data', 'Visible', 'off', ...
'Interpreter', 'none');
e = get(h, 'Extent');
delete(h);
sz = [e(3) e(4)] * 1.12;
end
function w = text_width(ax, str, fs)
%TEXT_WIDTH 指定字号下文本在数据坐标下的宽度。
h = text(ax, 0, 0, str, 'Units', 'data', 'Visible', 'off', ...
'FontSize', fs, 'Interpreter', 'none');
e = get(h, 'Extent');
delete(h);
w = e(3) * 1.04;
end
function s = marker_size(ax)
xl = ax.XLim;
yl = ax.YLim;
pos = ax.Position;
fpos = ancestor(ax, 'figure').Position;
w_px = pos(3) * fpos(3);
h_px = pos(4) * fpos(4);
s = 8 * max(diff(xl) / w_px, diff(yl) / h_px); % 约 8 px
end
+11
View File
@@ -0,0 +1,11 @@
function D = load_data(S, name)
%LOAD_DATA 读取 scripts/build_figure_data.py 预先写好的绘图数据并规范化文本字段。
path = fullfile(S.data_dir, [name '.mat']);
if ~isfile(path)
error('缺少绘图数据:%s(请先运行 scripts/build_figure_data.py --prepare)', path);
end
D = normalize_text(load(path));
if isfield(D, 'switch_') && ~isfield(D, 'switch_s')
D.switch_s = D.switch_;
end
end
+15
View File
@@ -0,0 +1,15 @@
function D = normalize_text(D)
%NORMALIZE_TEXT 把 load 出来的字符矩阵还原成字符串列表。
% scipy.io.savemat 会把 Python 的字符串列表写成三维 char 数组(或 char 矩阵),
% MATLAB 侧读回来是 char,不能按 {i} 取值,这里统一转成 cellstr。
fn = fieldnames(D);
for k = 1:numel(fn)
v = D.(fn{k});
if ischar(v) && ndims(v) > 2
D.(fn{k}) = cellstr(v);
elseif isstruct(v)
D.(fn{k}) = normalize_text(v);
end
end
end
+13
View File
@@ -0,0 +1,13 @@
function c = pick_color(S, name)
%PICK_COLOR 语义色名 → MATLAB 默认配色(不引入自定义调色板)。
switch name
case 'primary', c = S.c(1, :); % 几何区域、主体对象
case 'accent', c = S.c(2, :); % 真值源、关键结论
case 'warn', c = S.c(3, :); % 警示、未清除
case 'violet', c = S.c(4, :); % 直径圆等辅助对象
case 'success', c = S.c(5, :); % 覆盖、清除、通过
case 'grid', c = [0.5 0.5 0.5];
case 'muted', c = [0.45 0.45 0.45];
otherwise, c = [0 0 0];
end
end
+38
View File
@@ -0,0 +1,38 @@
function run_all_figures()
%RUN_ALL_FIGURES 生成 docs/figures 下的全部论文插图。
% 数据由 scripts/build_figure_data.py 写入 docs/figures/.data/*.mat。
% 本机图形子系统在同一批处理会话里连续渲染多张图会崩溃,故每张图单独起会话;
% 这里只作为 MATLAB 内的手动入口,逐张调用并记日志。
here = fileparts(mfilename('fullpath'));
addpath(here);
log = fullfile(here, 'run_all_figures.log');
items = { 'flow-overall-architecture', @fig_flow_overall
'steps-state-machine', @fig_steps_state_machine
'timing-attachment-example-105-111-194-199', @fig_timing_attachment
'timing-measured-q3-10-sources', @fig_timing_measured
'geo-q1-convex-intersection-zoom', @fig_geo_q1_meetup
'geo-q1-diameter-circle-counterexample', @fig_geo_q1_counterexample
'geo-q2-guaranteed-reception-disk', @fig_geo_q2_safe_disk
'geo-q3-seven-point-coverage', @fig_geo_q3_coverage
'geo-q4-minimum-cover-31-points', @fig_geo_q4_mincover
'replay-webui-q3-omnidirectional', @() fig_replay('replay_q3')
'replay-webui-q4-directional', @() fig_replay('replay_q4') };
fid = fopen(log, 'w');
failed = 0;
for i = 1:2:numel(items)
name = items{i};
fn = items{i + 1};
try
fn();
fprintf(fid, 'OK %s\n', name);
catch err
failed = failed + 1;
fprintf(fid, 'FAIL %s: %s\n', name, err.message);
end
end
fclose(fid);
if failed > 0
fprintf('有 %d 张图生成失败,详见 %s\n', failed, log);
end
end