Files
Robot_Arm/tests/verify.py
T
cbb 649bd69810 refactor(layout): src/ 收成顶层包 arm/,入口统一 python -m;提升承担构建的 scratch 脚本
单文件约束取消后,Python 侧没必要再靠 16 处 sys.path.insert 维持扁平模块互认。
现在按职责分包:
  arm/paths.py 路径唯一来源(含新增 CACHE、TPL)
  arm/constants.py 实测接口数值(原 armcore 的 M/C/CLR/FIT/MOTOR_TOL 段)
  arm/cad/{helpers,arm_build,part_tools,parts/} 几何与装配
  arm/kinematics.py(原 human_layout.py,唯一运动学事实源)
  arm/analysis/{sweep_v2,strength_check,torque_balance,finalize_travel,ws_sweep,gen_env_mesh}
  arm/dataio/{screw_mesh,draw_sheet}   arm/viewer/{build_viewer,shot}
  arm/build_model.py(原 arm_model.py)

顺带修掉的既有缺陷(都是实测撞出来的,不是预防性改动):
- arm_model 第二次 sys.path 跳到 gitignore 的 scratch/ 去 import 构建必需的包络脚本:
  ws_sweep/gen_env_mesh 提升进 arm/analysis/,缓存挪到 data/cache/(gitignore,可重生成)。
- finalize_travel / draw_sheet 顶层就是"算完写文件",一次 importlib 冒烟测试就把
  docs/travel.json 重写了一遍;现在被 import 直接抛错。
- strength_check 用 OUT.replace("/out","/web") 靠字符串形状推路径,改成直接用 WEB。
- build_viewer 里 WORKSPACE 使用点漏改导致 NameError —— 已由构建关卡抓到并修掉。

os._exit 的处置与计划里的假设相反:实测光是 `python -c "import arm.cad.helpers"`
就会在解释器退出时段错误(exit 139,OCC 静态析构),全流程跑完也一样。所以入口
脚本尾部保留 flush + os._exit,并把原因写进注释;被 import 时不生效。

关卡(tests/):
- verify.py 一条命令跑三层:产物 md5、IK 数值 exact 段、截图。
- 截图基线重做并分级:同一构建连跑两轮实测 102 张逐字节可复现(要求 md5 相同),
  3 张线束 x-ray 不可复现(mean<=0.0017、无像素差>8)改为与参考像素比容差;
  这 3 张不再入库,免得每次跑关卡都脏 git。
- 新增 ?view=sheet / ?sheet=N / ?theme / ?guide=N 四个 URL 参数:工程图与装配向导
  占了约一半 CSS 和 700 行 JS,原先没有任何截图覆盖,拆它们等于没有关卡。
- 关卡灵敏度已验:改坏 IK 基线一个数 -> FAIL;换旧版求解器 -> 5 处 exact 差异。

实测:python -m arm.build_model 退出码 0,全部产物 md5 与基线一致,
105 视图截图全过,IK exact 段逐字节一致。
2026-10-15 03:31:20 +08:00

173 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""重构期的不变量关卡。
python -m tests.verify # 数据哈希 + IK 数值(秒级,每步都跑)
python -m tests.verify --shots # 再加全套截图比对(约 3 分钟,前端改动后跑)
基线在 tests/baselines/ 下,随仓库走。产物按**文件名**匹配,不写死目录:
mesh 从 web/ 搬到 webapp/data/ 这类移动不该让关卡失效,也不该让我以为它通过了。
任何一项对不上就退出码 1。
"""
import hashlib
import io
import json
import os
import subprocess
import sys
sys.stdout.reconfigure(encoding="utf-8")
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
BASE = os.path.join(HERE, "baselines")
# 每个产物允许出现的目录,按优先级;找到第一个即取。
CANDIDATES = {
"mesh_arm.json": ["webapp/data", "web"],
"sheets.jsdata": ["webapp/data", "web"],
"travel.json": ["docs", "webapp/data"],
"travel_detail.json": ["docs", "webapp/data"],
"collide.json": ["docs", "webapp/data"],
"screws.json": ["docs"],
}
def md5(path):
h = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def read_list(name):
"""基线文件是 'hash filename';md5sum 的二进制模式会在文件名前加 '*',剥掉。"""
out = {}
for ln in io.open(os.path.join(BASE, name), encoding="utf-8"):
ln = ln.strip()
if ln:
h, f = ln.split()
out[f.lstrip("*")] = h
return out
def locate(fname, dirs):
for d in dirs:
p = os.path.join(REPO, d, fname)
if os.path.exists(p):
return p
return None
def check_data():
base = read_list("data_by_name.md5")
bad, rows = 0, []
for fname, dirs in CANDIDATES.items():
want = base.get(fname)
if want is None:
rows.append(("SKIP", fname, "基线里没有这项"))
continue
p = locate(fname, dirs)
if p is None:
bad += 1
rows.append(("MISS", fname, "找不到文件(试过的目录: %s)" % ", ".join(dirs)))
continue
got = md5(p)
ok = got == want
if not ok:
bad += 1
rows.append(("OK " if ok else "DIFF", fname,
os.path.relpath(p, REPO).replace("\\", "/") + " " + got[:12] +
("" if ok else " 基线 " + want[:12])))
for st, f, msg in rows:
print(" [%s] %-20s %s" % (st, f, msg))
return bad
def check_ik():
r = subprocess.run(["node", os.path.join(HERE, "ik_baseline.mjs"),
"--compare", os.path.join(BASE, "ik_exact.json")],
capture_output=True, text=True, cwd=REPO,
encoding="utf-8", errors="replace")
out = r.stdout or ""
for ln in out.splitlines():
print(" " + ln)
if r.returncode != 0:
print(" node 退出码 %d: %s" % (r.returncode, (r.stderr or "").strip()[:300]))
# 没有 "== exact" 这一行就说明探针根本没跑出比对结果(编码炸了、node 不在、路径错…),
# 绝不能因为"没报差异"就算通过。
if "== exact" not in out:
print(" [MISS] ik_baseline 没有输出比对结果,判 FAIL")
return 1
return 0 if r.returncode == 0 else 1
def check_shots():
"""两层判定,依据是"同一构建连跑两轮"的实测(scratch/_gate/flaky_scan.py):
104 张逐字节可复现 -> md5 必须相同;
3 张线束 x-ray 不可复现(mean<=0.0017、无像素差>8,纯抗锯齿噪声)
-> 与 tests/baselines/flaky/ 里的参考像素比,超阈值才算回归。
把不可复现的也要求 md5 相同,等于给每一步埋假 FAIL,最后只会被人肉忽略。"""
import numpy as np
from PIL import Image
base = read_list("shots_stable.md5")
th = json.load(io.open(os.path.join(BASE, "flaky", "thresholds.json"), encoding="utf-8"))
r = subprocess.run([sys.executable, "-m", "arm.viewer.shot"],
capture_output=True, text=True, cwd=REPO,
encoding="utf-8", errors="replace")
out = r.stdout or ""
if "all shots ok" not in out and "OK " not in out:
print(" [MISS] shot.py 没有正常输出(退出码 %s),判 FAIL" % r.returncode)
return 999
bad = [l for l in out.splitlines() if "FAIL" in l]
if bad:
print(" [MISS] 截图失败 %d 个: %s" % (len(bad), bad[:3]))
fails = len(bad)
for fname, want in sorted(base.items()):
p = os.path.join(REPO, fname.replace("/", os.sep))
if not os.path.exists(p):
print(" [MISS] %s" % fname)
fails += 1
continue
if md5(p) != want:
print(" [DIFF] %-40s" % fname)
fails += 1
print(" 逐字节比对: %d 张稳定视图,%d 张不一致" % (len(base), fails - len(bad)))
tol_fails = 0
for name, ref in sorted(th["views"].items()):
p = os.path.join(REPO, "docs", "shots", name + ".png")
q = os.path.join(BASE, "flaky", name + ".png")
if not (os.path.exists(p) and os.path.exists(q)):
print(" [MISS] 容差比对缺文件: %s" % name)
tol_fails += 1
continue
d = np.abs(np.asarray(Image.open(p).convert("RGB"), dtype=np.int16)
- np.asarray(Image.open(q).convert("RGB"), dtype=np.int16))
mean, mx = float(d.mean()), int(d.max())
over = mean > th["mean_max"] or mx > th["pixel_max"]
tol_fails += 1 if over else 0
print(" [%s] %-34s mean=%.5f (<= %.2f) 最大单像素差=%d (<= %d)"
% ("DIFF" if over else "OK ", name, mean, th["mean_max"], mx, th["pixel_max"]))
print(" 容差比对: %d 张噪声视图,%d 张超阈值" % (len(th["views"]), tol_fails))
return fails + tol_fails
def main():
argv = sys.argv[1:]
fails = 0
print("== 数据产物哈希 ==")
fails += check_data()
print("== IK 数值基线 ==")
fails += check_ik()
if "--shots" in argv:
print("== 截图逐字节比对 ==")
fails += check_shots()
print("RESULT:", "PASS" if fails == 0 else "FAIL(%d 项不一致)" % fails)
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())