419 lines
19 KiB
Python
419 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""Phase 0:ECAT 指标现场标定 —— 跑速度扫描之前,先确认每个计数器还有没有意义。
|
||
|
||
为什么必须有这一步:固件与主站都在被反复改(当前 ecat_native.c 有 239 行未提交改动,
|
||
批量区已从"变长紧排 3 帧/拍"换成 1.6.15 定长覆盖窗口),任何沿用历史结论的判据都可能
|
||
已经失效。本脚本只认现场读数,不认文档和历史。
|
||
|
||
标定八件事:
|
||
1 总线能否正常起(open + motor/start)
|
||
2 板上固件身份(台账 sha256 对 Release/*.hex 展平后的哈希)
|
||
3 批量区实际布局(rxraw.layout,不假设)
|
||
4 每拍入窗帧数分布(取 cadence 环,按拍采样,不受 HTTP 轮询速率影响)
|
||
5 丢帧率的主判据:**seq 值域 − 实测 distinct seq**(对槽位顺序不敏感)
|
||
对账判据:固件 OVF / bridge.drop —— 二者与主判据是否一致
|
||
6 撕裂类计数器是否还有功效(rx_torn / pdo_reentry,恒零即"测不到"≠"没坏")
|
||
7 主站错拍 skip_total 与达成率、cyc_us 占空
|
||
8 CPU 两口径(GD32 load_perm / 主机 native)+ 进程 PID 稳定性
|
||
|
||
用法:
|
||
python scripts/calibrate_ecat_metrics.py
|
||
python scripts/calibrate_ecat_metrics.py --seconds 60 --json lab/ecat_calib.json
|
||
python scripts/calibrate_ecat_metrics.py --no-start # 总线没起时不自动拉起
|
||
"""
|
||
import argparse
|
||
import collections
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from ecat_sweep_common import delta_saturate, delta_wrap, percentile, post
|
||
|
||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
DEFAULT_URL = "http://127.0.0.1:8090"
|
||
|
||
# 1.6.15 定长覆盖窗口槽布局(对应 ecat_native.c RXB_WIN_OFF_* / 固件 ECAT_RXW_OFF_*)
|
||
WIN_SLOT_SIZE, WIN_SLOTS = 44, 5
|
||
WIN_OFF_FLAGS, WIN_OFF_ID, WIN_OFF_SEQ, WIN_OFF_DLC = 2, 4, 8, 1
|
||
FLAG_WRITTEN = 0x01
|
||
|
||
|
||
def parse_win_slots(rawhex):
|
||
"""从 rxraw.raw 解出每个槽的 (id, seq)(跳过 bit0=未写过的空槽)。
|
||
|
||
parsed 字段不带 seq,而 seq 是这个布局的丢帧判据,必须自己解。
|
||
"""
|
||
try:
|
||
buf = bytes.fromhex(rawhex or "")
|
||
except ValueError:
|
||
return []
|
||
out = []
|
||
for k in range(WIN_SLOTS):
|
||
o = k * WIN_SLOT_SIZE
|
||
if o + WIN_SLOT_SIZE > len(buf):
|
||
break
|
||
if not buf[o + WIN_OFF_FLAGS] & FLAG_WRITTEN:
|
||
continue
|
||
out.append((int.from_bytes(buf[o + WIN_OFF_ID:o + WIN_OFF_ID + 4], "little"),
|
||
int.from_bytes(buf[o + WIN_OFF_SEQ:o + WIN_OFF_SEQ + 4], "little")))
|
||
return out
|
||
|
||
|
||
def window_coverage(seqmap, slot_slots=WIN_SLOTS):
|
||
"""覆盖窗口观测到的 seq 值域 / distinct seq 数(**描述性指标,不是丢帧判据**)。
|
||
|
||
曾用「值域 − distinct」当丢帧主判据,已被现场证伪:窗口只有 5 槽、HTTP 又只几
|
||
Hz,值域内根本不可能观测到全部 seq,实测报出 99.686% "丢帧"而固件 OVF=0,自相
|
||
矛盾 —— 它量的是**采样覆盖率**。真丢帧主判据是 bridge.drop(固件
|
||
canfd.h:53 rx_drop_count,已实测确认为回绕计数器且增量与 rx_count 增量相等)。
|
||
"""
|
||
out = {}
|
||
for fid, obs in seqmap.items():
|
||
if not obs:
|
||
continue
|
||
lo, hi = min(obs), max(obs)
|
||
out[fid] = {"n_obs": len(obs), "span": hi - lo + 1,
|
||
"coverage_pct": round(len(obs) / (hi - lo + 1) * 100, 3)}
|
||
return {"per_id": out, "ids": len(out),
|
||
"note": "覆盖率仅说明观测密度,不可当丢帧率引用"}
|
||
|
||
|
||
def cadence_rings(url):
|
||
"""按拍采到的环形缓冲:每拍 count/lost/pending/wkc 的真实分布。
|
||
|
||
HTTP 轮询只有几 Hz,用它看"每拍几帧"是欠采样;cadence 环由 native 自己在
|
||
每拍写,512 拍一段,才是每拍口径的真值。
|
||
"""
|
||
try:
|
||
c = post(url, "/api/can/cadence")
|
||
except Exception as e:
|
||
return {"error": str(e)[:120]}
|
||
cols, rows = c.get("cols") or [], c.get("rows") or []
|
||
if not rows:
|
||
return {"error": "环为空"}
|
||
ix = {k: i for i, k in enumerate(cols)}
|
||
def col(name):
|
||
return [r[ix[name]] for r in rows] if name in ix else []
|
||
out = {"n": len(rows), "period_ms": c.get("period_ms")}
|
||
for name in ("count", "lost", "pending", "backlog", "wkc"):
|
||
v = col(name)
|
||
if v:
|
||
out[name + "_hist"] = {str(k): n for k, n in sorted(collections.Counter(v).items())}
|
||
out[name + "_max"] = max(v)
|
||
span_ms = (col("t")[-1] - col("t")[0]) if len(rows) > 1 else 0
|
||
out["span_ms"] = round(span_ms, 1)
|
||
if span_ms > 0 and "rxcnt" in ix:
|
||
out["lambda_per_s"] = round((col("rxcnt")[-1] - col("rxcnt")[0]) / (span_ms / 1000.0), 1)
|
||
out["gseq_per_s"] = round((col("gseq")[-1] - col("gseq")[0]) / (span_ms / 1000.0), 1) \
|
||
if "gseq" in ix else None
|
||
return out
|
||
|
||
|
||
def find_native_pid(url):
|
||
"""反查占用该端口的进程 PID。PID 变了 = 换了实例 = 之前采的全部作废。"""
|
||
try:
|
||
out = subprocess.check_output(["netstat", "-ano", "-p", "TCP"],
|
||
text=True, errors="replace")
|
||
except Exception as e:
|
||
return None, f"netstat 失败: {e}"
|
||
port = url.rsplit(":", 1)[-1].strip("/")
|
||
for line in out.splitlines():
|
||
cols = line.split()
|
||
if len(cols) >= 5 and cols[0] == "TCP" and cols[3] == "LISTENING" \
|
||
and cols[1].endswith(":" + port):
|
||
return int(cols[-1]), None
|
||
return None, f"没人监听 :{port}"
|
||
|
||
|
||
def firmware_identity():
|
||
"""台账 sha256 对固件产物。
|
||
|
||
口径必须与烧写脚本一致:`lab/_last_gd32_sha.txt` 记的是 Intel-Hex **展平成
|
||
APP 基址连续 blob 后**的 sha256(flash_gd32.py:91-92),不是 .hex 文件本身的哈希,
|
||
产物也是 `.hex` 而非 `.bin`。按原始字节算或按 .bin 过滤会一个都扫不到,
|
||
把"本可判定"误报成"无法判定"。
|
||
|
||
即便命中也只能说明"板上镜像与当前产物同源";产物比台账新时必然不等。
|
||
**不能据此推断板上正在运行的版本** —— 那要设备回报版本字。
|
||
"""
|
||
shafile = os.path.join(REPO, "lab", "_last_gd32_sha.txt")
|
||
rec = {"ledger": None, "ledger_path": shafile, "match": None,
|
||
"candidates": [], "method": "hex_to_flat + sha256"}
|
||
if not os.path.isfile(shafile):
|
||
rec["error"] = "台账不存在"
|
||
return rec
|
||
with open(shafile, encoding="utf-8", errors="replace") as f:
|
||
rec["ledger"] = f.read().strip()
|
||
try:
|
||
from flash_gd32 import hex_to_flat
|
||
except Exception as e:
|
||
rec["error"] = f"无法复用烧写脚本的展平算法({e})"
|
||
return rec
|
||
app = os.path.abspath(os.path.join(REPO, "..", "..", "DCU_APP", "out"))
|
||
if not os.path.isdir(app):
|
||
rec["error"] = f"固件产物目录不存在: {app}"
|
||
return rec
|
||
hexes = []
|
||
for dirpath, _d, files in os.walk(app):
|
||
hexes += [os.path.join(dirpath, fn) for fn in files if fn.endswith(".hex")]
|
||
if not hexes:
|
||
rec["error"] = "DCU_APP/out 下没有 .hex 产物"
|
||
return rec
|
||
for p in sorted(hexes):
|
||
try:
|
||
d = hashlib.sha256(hex_to_flat(p)).hexdigest()
|
||
except (OSError, SystemExit, ValueError) as e:
|
||
rec["candidates"].append({"path": p, "error": str(e)[:120]})
|
||
continue
|
||
rec["candidates"].append({"path": p, "sha256": d[:16], "mtime": int(os.path.getmtime(p))})
|
||
if d == rec["ledger"]:
|
||
rec["match"] = p
|
||
rec["candidates"].sort(key=lambda c: -c.get("mtime", 0))
|
||
rec["candidates"] = rec["candidates"][:5]
|
||
return rec
|
||
|
||
|
||
def ensure_loop(url, autostart, substr):
|
||
st = post(url, "/api/motor/state")
|
||
if st.get("ok") and st.get("cycles"):
|
||
return st, "已在跑"
|
||
if not autostart:
|
||
return st, "循环未跑(--no-start)"
|
||
op = post(url, "/api/open", {"substr": substr}, timeout=25.0)
|
||
if not op.get("ok"):
|
||
return op, "open 失败: %s" % (op.get("log") or [])
|
||
ms = post(url, "/api/motor/start", {"period_ms": st.get("period_ms") or 1.0}, timeout=40.0)
|
||
if not ms.get("ok"):
|
||
return ms, "motor/start 失败(典型=SSC 邮箱楔死): %s" % (ms.get("log") or [])
|
||
return post(url, "/api/motor/state"), "已自动 open+start"
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="ECAT 指标现场标定")
|
||
ap.add_argument("--url", default=DEFAULT_URL)
|
||
ap.add_argument("--seconds", type=float, default=30.0)
|
||
ap.add_argument("--hz", type=float, default=10.0,
|
||
help="HTTP 采样频率。u16 的 drop 在 4000/s 下 16s 就绕一圈,"
|
||
"采样间隔须远小于回绕周期")
|
||
ap.add_argument("--no-start", action="store_true")
|
||
ap.add_argument("--substr", default="I210")
|
||
ap.add_argument("--json", default=None)
|
||
a = ap.parse_args()
|
||
|
||
rep = {"url": a.url, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"seconds": a.seconds, "sample_hz": a.hz, "logical_cpus": os.cpu_count()}
|
||
pid, perr = find_native_pid(a.url)
|
||
rep["native_pid"], rep["native_pid_error"] = pid, perr
|
||
rep["firmware"] = firmware_identity()
|
||
rep["cadence"] = cadence_rings(a.url)
|
||
|
||
st, how = ensure_loop(a.url, not a.no_start, a.substr)
|
||
rep["loop_start"] = {"how": how, "ok": bool(st.get("ok"))}
|
||
if not st.get("ok"):
|
||
print("总线起不来,标定终止:", how)
|
||
return 2
|
||
rep["period_ms_set"] = st.get("period_ms")
|
||
rep["pcore_mask"] = st.get("pcore_mask")
|
||
|
||
proc = None
|
||
try:
|
||
import psutil
|
||
for p in psutil.process_iter(["name", "pid"]):
|
||
if p.info["pid"] == pid:
|
||
proc = psutil.Process(pid)
|
||
proc.cpu_percent(None)
|
||
break
|
||
rep["psutil"] = proc is not None
|
||
except Exception as e:
|
||
rep["psutil"] = f"不可用({e})"
|
||
|
||
interval = 1.0 / a.hz
|
||
seqmap = collections.defaultdict(set)
|
||
counts, loads, hc1, hctot, cyc_hz = collections.Counter(), [], [], [], []
|
||
drop_rate, rx_rate, cyc_samples = [], [], []
|
||
drop_untrusted = 0
|
||
skip_first = skip_last = None
|
||
torn_c = reentry_c = 0
|
||
torn_cap = reentry_cap = False
|
||
torn_prev = reentry_prev = None
|
||
prev = None
|
||
prev_t = None
|
||
layout = collections.Counter()
|
||
err = iters = 0
|
||
t_end = time.perf_counter() + a.seconds
|
||
while time.perf_counter() < t_end:
|
||
tc = time.perf_counter()
|
||
try:
|
||
s = post(a.url, "/api/motor/state")
|
||
L = post(a.url, "/api/app/load")
|
||
R = post(a.url, "/api/motor/rxraw")
|
||
D = post(a.url, "/api/motor/dbg")
|
||
except Exception:
|
||
err += 1
|
||
time.sleep(interval)
|
||
continue
|
||
iters += 1
|
||
b = s.get("bridge") or {}
|
||
rb = s.get("rx_batch") or {}
|
||
cur = {"cycles": s.get("cycles"), "drop": b.get("drop"), "rx": b.get("rx_count")}
|
||
if prev is not None and prev_t is not None and tc > prev_t:
|
||
dt = tc - prev_t
|
||
if cur["cycles"] is not None and prev["cycles"] is not None:
|
||
hz = (cur["cycles"] - prev["cycles"]) / dt
|
||
if 0 < hz < 1e6:
|
||
cyc_hz.append(hz)
|
||
if cur["drop"] is not None and prev["drop"] is not None:
|
||
d = delta_wrap(prev["drop"], cur["drop"], 16)
|
||
# u16 回绕计数器:单次增量逼近模数一半即说明两次采样间已绕圈,
|
||
# 该次不可信,只能丢弃并计数暴露采样率不足(真值下降同理)。
|
||
if d < 32768:
|
||
drop_rate.append(d / dt)
|
||
else:
|
||
drop_untrusted += 1
|
||
if cur["rx"] is not None and prev["rx"] is not None:
|
||
dr = delta_wrap(prev["rx"], cur["rx"], 32)
|
||
if dr < (1 << 31):
|
||
rx_rate.append(dr / dt)
|
||
for fid, sq in parse_win_slots(R.get("raw")):
|
||
seqmap[fid].add(sq)
|
||
counts[rb.get("count")] += 1
|
||
layout[R.get("layout")] += 1
|
||
if L.get("valid") and L.get("load_perm") is not None:
|
||
loads.append(L["load_perm"] / 10.0)
|
||
if L.get("rx_torn") is not None:
|
||
if torn_prev is not None:
|
||
d, capped = delta_saturate(torn_prev, L["rx_torn"])
|
||
torn_c += d
|
||
torn_cap |= capped or L["rx_torn"] >= 65535
|
||
torn_prev = L["rx_torn"]
|
||
if L.get("pdo_reentry") is not None:
|
||
if reentry_prev is not None:
|
||
d, capped = delta_saturate(reentry_prev, L["pdo_reentry"])
|
||
reentry_c += d
|
||
reentry_cap |= capped or L["pdo_reentry"] >= 65535
|
||
reentry_prev = L["pdo_reentry"]
|
||
sk = D.get("skip_total")
|
||
if sk is not None:
|
||
if skip_first is None:
|
||
skip_first = sk
|
||
skip_last = sk
|
||
if s.get("cyc_us"):
|
||
cyc_samples.append(s["cyc_us"])
|
||
if proc is not None:
|
||
try:
|
||
c = proc.cpu_percent(None)
|
||
hc1.append(c)
|
||
hctot.append(c / max(1, rep["logical_cpus"] or 1))
|
||
except Exception:
|
||
pass
|
||
prev, prev_t = cur, tc
|
||
time.sleep(max(0.0, interval - (time.perf_counter() - tc)))
|
||
|
||
dur = time.perf_counter() - (t_end - a.seconds)
|
||
stf = post(a.url, "/api/motor/state")
|
||
pid2, _ = find_native_pid(a.url)
|
||
cad = rep["cadence"]
|
||
|
||
lam = percentile(rx_rate, 0.5) or cad.get("lambda_per_s") or 0
|
||
dropr = percentile(drop_rate, 0.5) or 0
|
||
rep["dur_s"] = round(dur, 2)
|
||
rep["iters"] = iters
|
||
rep["errors"] = err
|
||
rep["drop_untrusted_samples"] = drop_untrusted
|
||
rep["period_ms_effective"] = stf.get("period_ms")
|
||
rep["wkc"] = [stf.get("wkc"), stf.get("expected_wkc")]
|
||
rep["wr_end"] = stf.get("wr")
|
||
rep["native_pid_end"] = pid2
|
||
rep["pid_stable"] = (pid == pid2)
|
||
rep["layout_seen"] = dict(layout)
|
||
rep["rx_batch_count_hist_http"] = {str(k): v for k, v in sorted(counts.items(), key=lambda x: (x[0] is None, x[0]))}
|
||
rep["window_coverage"] = window_coverage(seqmap)
|
||
rep["can_lambda_per_s"] = round(lam, 1)
|
||
rep["drop_per_s"] = round(dropr, 1)
|
||
rep["drop_pct_of_lambda"] = round(dropr / lam * 100, 2) if lam else None
|
||
rep["achieved_hz_p50"] = round(percentile(cyc_hz, 0.5) or 0, 1) if cyc_hz else None
|
||
pm = rep["period_ms_effective"] or 1
|
||
rep["achieve_pct"] = round((rep["achieved_hz_p50"] or 0) / (1000.0 / pm) * 100, 1)
|
||
rep["skip_delta"] = (skip_last - skip_first) if (skip_first is not None and skip_last is not None) else None
|
||
rep["skip_per_s"] = round((rep["skip_delta"] or 0) / dur, 2) if rep["skip_delta"] is not None else None
|
||
rep["skip_total_cum"] = skip_last
|
||
rep["rx_torn_delta"] = torn_c
|
||
rep["rx_torn_capped"] = torn_cap
|
||
rep["pdo_reentry_delta"] = reentry_c
|
||
rep["pdo_reentry_capped"] = reentry_cap
|
||
tot = [(c.get("total") or {}) for c in cyc_samples if c.get("total")]
|
||
rep["cyc_us_total"] = {"p50": percentile([t.get("p50") for t in tot if t.get("p50") is not None], 0.5),
|
||
"p90": percentile([t.get("p90") for t in tot if t.get("p90") is not None], 0.5),
|
||
"max": max([t.get("max") or 0 for t in tot], default=None)}
|
||
rep["cyc_us_late_lost_sample"] = tot[0] if tot else None
|
||
rep["gd32_load_pct_p50"] = round(percentile(loads, 0.5) or 0, 1)
|
||
rep["host_cpu_1core_p50"] = round(percentile(hc1, 0.5) or 0, 1)
|
||
rep["host_cpu_total_p50"] = round(percentile(hctot, 0.5) or 0, 2)
|
||
|
||
cap = cad.get("count_max") or 0
|
||
rep["per_cycle_cap_observed"] = cap
|
||
rep["capacity_model"] = {
|
||
"lambda_per_s": round(lam, 1), "per_cycle_cap": cap,
|
||
"drain_per_s_at_current_T": round(cap / pm * 1000, 0) if pm else None,
|
||
"knee_ms_if_linear": round(cap / lam * 1000, 3) if lam and cap else None,
|
||
"note": "cap 取自 cadence 环实测 count 峰值;拐点以扫描实测为准,此值仅供事后对照"}
|
||
rep["power"] = {
|
||
"drop_rx_queue": dropr > 0,
|
||
"skip_total": (rep["skip_delta"] or 0) > 0,
|
||
"achieve_deficit": (rep["achieve_pct"] or 100) < 99.0,
|
||
"rx_torn": torn_c > 0,
|
||
"pdo_reentry": reentry_c > 0,
|
||
"cadence_lost_or_pending": bool((cad.get("lost_max") or 0) or (cad.get("pending_max") or 0)),
|
||
"cyc_us_late_lost": bool((rep["cyc_us_late_lost_sample"] or {}).get("late")
|
||
or (rep["cyc_us_late_lost_sample"] or {}).get("lost"))}
|
||
|
||
print(f"=== Phase 0 标定 · {dur:.0f}s · HTTP 采样 {iters} 轮(错 {err}) @ {a.hz:g}Hz ===")
|
||
print(f"native PID {pid} -> {pid2} 稳定={rep['pid_stable']} 钉核 mask={rep['pcore_mask']}")
|
||
fw = rep["firmware"]
|
||
print(f"固件台账 {str(fw.get('ledger'))[:16]}… 匹配={os.path.basename(fw['match']) if fw.get('match') else fw.get('error') or '无'}")
|
||
print(f"周期 设定 {rep['period_ms_set']}ms -> 生效 {pm}ms 实测 {rep['achieved_hz_p50']}Hz 达成率 {rep['achieve_pct']}%")
|
||
print(f"批量区布局 {dict(layout)}")
|
||
print(f"每拍入窗帧数(cadence 环 {cad.get('n')}拍): count={cad.get('count_hist')} "
|
||
f"lost={cad.get('lost_hist')} pending={cad.get('pending_hist')}")
|
||
print(f" ↑ 每拍口径必须用环,HTTP 轮询看 count 是欠采样")
|
||
print(f"【主判据】λ={lam:.0f}/s drop={dropr:.0f}/s 丢帧占 λ = {rep['drop_pct_of_lambda']}%"
|
||
f" (drop=固件 canfd.h rx_drop_count,CAN RX 队列(kRxQueueDepth=16)满即丢)")
|
||
if drop_untrusted:
|
||
print(f" !! {drop_untrusted} 个采样点单次增量 >32768,说明已绕圈 -> 采样率不足,提高 --hz")
|
||
print(f" 对账: cadence lost_max={cad.get('lost_max')} pending_max={cad.get('pending_max')}"
|
||
f"(覆盖窗口下这两个旧计数器语义已改,仅供对账)")
|
||
print(f" 窗口 seq 覆盖率(非丢帧率!): {rep['window_coverage']['per_id']}")
|
||
print(f"rx_torn +{torn_c}(封顶={torn_cap}) pdo_reentry +{reentry_c}(封顶={reentry_cap})")
|
||
print(f"主站错拍 skip_total Δ={rep['skip_delta']} = {rep['skip_per_s']}/s(累计 {rep['skip_total_cum']})")
|
||
print(f"cyc_us.total p50={rep['cyc_us_total']['p50']} p90={rep['cyc_us_total']['p90']} "
|
||
f"max={rep['cyc_us_total']['max']} -> 占空 {round((rep['cyc_us_total']['p50'] or 0)/(pm*1000)*100,1)}%")
|
||
print(f"CPU: GD32 load_perm {rep['gd32_load_pct_p50']}% 主机 native 单核 {rep['host_cpu_1core_p50']}% "
|
||
f"/ 整机 {rep['host_cpu_total_p50']}%(逻辑核 {rep['logical_cpus']})")
|
||
print(f"wkc {rep['wkc']} wr={rep['wr_end']}")
|
||
km = rep["capacity_model"]
|
||
print(f"\n容量对照: cap={km['per_cycle_cap']} λ={km['lambda_per_s']:.0f} "
|
||
f"当前档排空 {km['drain_per_s_at_current_T']} 线性外推拐点 {km['knee_ms_if_linear']}ms"
|
||
f"(仅事后对照,拐点以扫描实测为准)")
|
||
dead = [k for k, v in rep["power"].items() if not v]
|
||
live = [k for k, v in rep["power"].items() if v]
|
||
print("有功效:", live or "无")
|
||
if dead:
|
||
print(f"无功效: {dead}")
|
||
print(" → 这些轴在报告里只能写「本次测不到」,**禁止**写成「无故障/已修好」")
|
||
|
||
if a.json:
|
||
os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True)
|
||
with open(a.json, "w", encoding="utf-8") as f:
|
||
json.dump(rep, f, ensure_ascii=False, indent=2)
|
||
print(f"\n标定结果: {a.json}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|