代码解析(四):task3_serial/ —— 用 3002 条真实报文做一次逐行自证

这是系列的最后一篇。前三个部分讲的是「代码怎么写的」,这一篇要回答一个更硬的问题:你凭什么说它是对的? 本篇范围 :task3_serial/ 两个脚本(209 行)+ config.json + 真实接收日志(3002 条报文)。 核心结论预告:协议层是全项目质量最高的部分------3002 条报文的 XOR 校验全部通过;## 1. 分层设计:这个文件是全项目的枢纽

task3_serial/ 只有两个脚本,但它们的划分方式决定了这一篇能写出多少可验证的结论:

代码块

复制代码
cv1_protocol.py            →  纯函数:数字 → 字符串 → 字节(只依赖 pyserial)
detect_apriltag_serial.py  →  主循环:相机、检测、位姿、节流、发送

关键事实:cv1_protocol.py 里没有 import cv2。 它只有一个外部依赖 serial,而 serial 只在 open_serial() 里被用到。

这一点为什么重要?因为它把「这条链路最难验证的部分」变得可以脱离硬件验证 。相机要接、Tag 要打印、串口对要配置,只有报文格式是纯逻辑------而报文格式恰恰是电控和视觉之间唯一的契约。把契约层单独放一个文件,就等于给这个契约装了一道可以自动检查的门。

后面的第 4 节和第 7 节,全部建立在这个划分之上。

2. cv1_protocol.py 逐段

整个文件 49 行,只有三个函数。

2.1 xor_checksum:范围是唯一的坑

python

python 复制代码
def xor_checksum(payload: str) -> str:
    """计算 $ 之后、* 之前全部 ASCII 字节的 XOR 校验。"""
    checksum = 0
    for byte in payload.encode("ascii"):
        checksum ^= byte
    return f"{checksum:02X}"

三点:

  • encode("ascii") 之后再异或,而不是对 str 直接异或。 str 在 Python 里是 Unicode 码点序列,对字符做 ^ 会直接抛 TypeError;更重要的是,协议规定的是字节层面的异或,必须先编码才能谈「字节」。
  • f"{checksum:02X}" 同时解决了补零和大小写。 X 是大写十六进制,02 保证一位数时补零------所以 0x0F 输出 "0F" 而不是 "F"。如果不写 02,校验和字段就会在 1 位和 2 位之间变化,接收端按定长解析就会错位。
  • 函数只接受 payload($ 与 * 之间的部分),范围由调用方保证。 这意味着「范围」这件事没有写在函数内部,是靠调用时的参数约定的------这是唯一值得商榷的地方(见 2.4)。

2.2 make_packet:字段名自带单位

python

python 复制代码
def make_packet(
    seq: int, t_ms: int, valid: int, tag_id: int,
    x_mm: float, y_mm: float, z_mm: float,
    rx: float, ry: float, rz: float,
) -> bytes:
    """按照 CV1 格式生成以真实 CRLF 结尾的 ASCII 字节串。"""
    payload = (
        f"CV1,{seq},{t_ms},{valid},{tag_id},"
        f"{x_mm:.1f},{y_mm:.1f},{z_mm:.1f},"
        f"{rx:.6f},{ry:.6f},{rz:.6f}"
    )
    return f"${payload}*{xor_checksum(payload)}\r\n".encode("ascii")

参数名把单位写进去了:x_mm 而不是 x。 这是个小设计,但它挡住了一整类错误------协议规定坐标必须是毫米,而 OpenCV 解出来的 tvec 是米。如果参数叫 x,调用方很容易把米直接传进来,然后电控收到一个小了 1000 倍的坐标。叫 x_mm 之后,make_packet(..., x_m, ...) 这种写法在阅读时就会显得刺眼。

另一个细节:\r\n 是真实的两个字节。 因为字符串里写的是转义序列 \r\n(而不是字面的反斜杠+r),encode("ascii") 之后就是 0D 0A。如果误写成 "\r\n",发出去的会是 4 个可见字符 \r\n ,串口助手上就会看到 ...*33\r\n 这种诡异的显示------这是一个很常见的复制粘贴事故,而这里的写法是对的。

还有一处刻意的「不做」:make_packet 不负责单位换算。 传进来 1000.0 就输出 1000.0。这样做的好处是函数保持纯粹(无隐藏行为),代价是调用方必须记得乘 1000。这是一个合理的取舍,因为换算只发生在唯一一个调用点,而隐藏的单位换算反而会让协议层变得难以推理。

2.3 open_serial:把串口参数一次写全

python

ini 复制代码
    return serial.Serial(
        port=port,
        baudrate=baudrate,
        bytesize=serial.EIGHTBITS,
        parity=serial.PARITY_NONE,
        stopbits=serial.STOPBITS_ONE,
        timeout=1,
        xonxoff=False,
        rtscts=False,
        dsrdtr=False,
    )

把这些参数逐个翻译

参数 它在回答的问题 通俗说法
port 走哪根线 "COM11 这个口"
baudrate 一个 bit 摆多久 快慢
bytesize 一个字节用几个 bit 一般 8 位
parity 要不要加一位查错 8N1 的 N = 不要
stopbits 字节之间隔多久 8N1 的 1 = 隔 1 位
timeout 读不到数据最多等多久 超时
xonxoff rtscts dsrdtr 对方处理不过来时怎么喊停 流控

8N1 = 数据位 8、无校验(None)、停止位 1。

115200/8N1 是协议规定的,这里逐项显式写出,而且把三种流控全部关掉。

为什么关流控值得单独提一句? 因为 pyserial 的默认值在跨平台/跨驱动时并不总是可靠,而虚拟串口对(com0com 之类)对 RTS/DTR 的处理和真实芯片(CH340、CP2102)又不一样。显式写出 xonxoff=False, rtscts=False, dsrdtr=False,等于把「这个通信不需要握手」写进了代码,避免某天换一台电脑、换一根线之后出现「能发但收不到」的怪现象。

2.4 唯一可以改进的设计点

xor_checksum 接受的是「$ 与 * 之间的内容」,但这个约束只写在 docstring 和调用点。如果哪天有人拿完整的 $CV1,...*33 字符串去调它,会得到一个错误的校验和而不报错。

更稳的做法是让函数自己负责切分:

python

python 复制代码
def xor_checksum(line: str) -> str:
    """接受完整报文或裸 payload,二者都能处理。"""
    body = line[1:line.index("*")] if line.startswith("$") else line
    checksum = 0
    for byte in body.encode("ascii"):
        checksum ^= byte
    return f"{checksum:02X}"

或者更彻底一点,加一个 parse_packet() 反向函数,让「组帧」和「解帧」成对存在------校验函数和一个能独立验证它的解析函数放在一起,才构成一个完整的协议模块。

3. 独立测试:15 项断言,全部通过

因为协议层不依赖相机和串口,我给它写了一个不需要任何硬件的测试(把 serial 换成一个空壳模块即可导入):

bash

bash 复制代码
python blog/tools/test_cv1_packet.py

代码块

csharp 复制代码
[PASS] README 示例报文逐字节一致
[PASS] 结尾为真实 CRLF (0D 0A)   末两字节: b'\r\n'
[PASS] XOR 等于手工计算的 $ 与 * 之间字节异或   手工 33 / 函数 33
[PASS] XOR 确实不含 $(若含则值不同)   不含 33 vs 含 17
[PASS] XOR 确实不含 *(若含则值不同)   不含 33 vs 含 19
[PASS] 校验和补零为两位   xor_checksum('A') = '41'
[PASS] 校验和为大写   '41'
[PASS] 无效报文 valid=0 / id=-1
[PASS] 无效报文的坐标与旋转都是干净的零(无负零)
[PASS] 坐标字段匹配 -?\d+.\d   ['-123.5', '-0.0', '0.1']
[PASS] 旋转字段匹配 -?\d+.\d{6}   ['-0.123457', '1.500000', '0.000000']
[PASS] 序号 4294967295 原样输出
[PASS] make_packet 不做单位换算(传入值即输出值)
[PASS] 报文长度可变(接收端必须按分隔符切分,不能按定长读取)   最短 57 字节 / 最长 86 字节
[PASS] 串口配置 115200/8N1 无流控
通过 15 / 15

四个值得单独讲的结论:

(1) 根 README 里的示例报文是真的。 文档里写的

代码块

bash 复制代码
$CV1,42,12345,1,0,100.0,-50.0,800.0,0.000000,0.000000,0.000000*33\r\n

用 make_packet(42, 12345, 1, 0, 100.0, -50.0, 800.0, 0.0, 0.0, 0.0) 生成,结果逐字节相同 ,包括那个 *33。这说明示例是实际跑出来的,不是手写编的------文档示例能被代码复现,本身就是一种可信度。

(2) XOR 的范围可以用反证法确认。 如果校验和多算了 $,结果会从 33 变成 17;多算了 *,会变成 19。这两个数字不同,证明「不含 $ 和不含 *」不是巧合------如果范围写错的收益恰好为零,这个测试就没有意义;这里的收益是显著的,所以结论扎实。

(3) 报文长度是可变的:最短 57 字节,最长 86 字节。

字段 长度变化来源
seq 1 位 → 10 位(0 → 4294967295)
t_ms 1 位 → 8 位
坐标 负号 + 整数位数会变

这条结论直接决定接收端该怎么写:不能按定长读取,必须按分隔符切分。 而协议用 \r\n 作为结束符,正好提供了这个切分点------于是「用 CRLF 结尾」从一个风格选择变成了一个必要条件。如果当初选了「按定长 64 字节读」,遇到长序号就会解析错位。

(4) 一个实测到的行为:极小负值会被格式化成 -0.0。

代码块

bash 复制代码
$CV1,3,250,1,7,-123.5,-0.0,0.1,-0.123457,1.500000,0.000000*3D

-1e-9 这样的值(比如浮点误差导致 x 本应为 0 却带了负号)会输出成 -0.0。

  • 好消息 :-0.0 是合法的浮点字面量,atof / strtof / C++ 的 std::stof 都能正常解析成 0,所以绝大多数接收端不受影响。
  • 要注意 :如果电控侧用正则或字符串比较 做严格校验(比如要求字段必须匹配 ^\d+.\d$),那个多出来的减号会让这一帧被判为非法。

4. detect_apriltag_serial.py 逐段

4.1 启动链

python

ini 复制代码
    root = Path(__file__).parent
    config_path = Path(args.config)
    if not config_path.is_absolute():
        config_path = root / config_path
    cfg = json.loads(config_path.read_text(encoding="utf-8"))

这次的相对路径处理是全项目最正确的一处: 它拿 __file__ 的父目录做基准,而不是拿当前工作目录。这意味着从任何目录运行都能找到 config.json ------对比 task1_lightbar/detect_video.py 和 task2_apriltag/*.py 里「必须从仓库根目录运行」的硬编码相对路径,这里是明显更成熟的写法。

标定文件同理:

python

ini 复制代码
    calibration_file = Path(cfg.get("calibration_file", "../task2_apriltag/output/camera_params.npz"))
    if not calibration_file.is_absolute():
        calibration_file = root / calibration_file
    if not calibration_file.exists():
        print(f"标定文件不存在: {calibration_file}")
        return 1

「配置里的路径相对配置文件解释」 是一个很好的约定:配置文件搬到哪,它的相对路径就跟着搬到哪。

接着是检测器与物点:

python

ini 复制代码
    dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_APRILTAG_36h11)
    detector = cv2.aruco.ArucoDetector(dictionary, cv2.aruco.DetectorParameters())
    tag_size = float(cfg["tag_size_m"])
    object_points = np.array([
        [-tag_size / 2,  tag_size / 2, 0],
        [ tag_size / 2,  tag_size / 2, 0],
        [ tag_size / 2, -tag_size / 2, 0],
        [-tag_size / 2, -tag_size / 2, 0],
    ], np.float32)

这里用的是 OpenCV 4.7+ 的新 API (ArucoDetector 取代了旧的 cv2.aruco.detectMarkers 全局函数),配合 requirements.txt 里锁定的 opencv-python==5.0.0.93,没问题。

物点的顺序与 ArUco 的角点约定配套------这个顺序为什么不能随便写,第 7 节会用合成 Tag 实验给出量化答案。

4.2 主循环:所有失败路径都汇聚到同一个出口

python

ini 复制代码
            while True:
                frame_start = time.monotonic()
                ok, frame = camera.read()
                if not ok:
                    print("无法读取摄像头")
                    break

                valid = 0
                tag_id = -1
                x_mm = y_mm = z_mm = 0.0
                rx = ry = rz = 0.0

                corners, ids, _ = detector.detectMarkers(frame)
                if ids is not None:
                    ...
                        pose_ok, rvec, tvec = cv2.solvePnP(...)
                        if pose_ok and int(detected_id) == args.target_id:
                            valid = 1
                            ...
                t_ms = int((time.monotonic() - start_time) * 1000)
                packet = make_packet(seq, t_ms, valid, tag_id, ...)
                sender.write(packet)

这段的结构值得专门表扬:valid 和所有字段在每轮循环开头被重置,然后只有「检测到 + 解算成功 + ID 匹配」三条同时成立时才会被改写,最后无条件发一帧。

由此得到的性质是:「没检测到」「ID 不匹配」「solvePnP 失败」三种情况走的都是同一条路径 ------不是 continue 跳过发送,而是老老实实发一帧 valid=0。这正是心跳设计的核心:安静的链路无法被区分于断掉的链路。

对比一下常见的写法(if not found: continue)会带来什么问题:接收端看到的是「数据停了」,它无法判断是目标不在画面里、还是视觉程序崩了、还是线松了。而这个实现让「没有数据」只剩下一种解释:真的断了。

4.3 10 Hz 节流:写法是对的

python

ini 复制代码
    send_period = 0.1

    ...
                remaining = send_period - (time.monotonic() - frame_start)
                if remaining > 0:
                    time.sleep(remaining)

这个模式(先算出「本轮到该发下一帧还有多久」,再补睡差额)比常见的 time.sleep(0.1) 正确得多:

写法 实际周期 长期影响
time.sleep(0.1) 放在处理之后 0.1 s + 处理耗时 误差累积,实际频率持续低于 10 Hz
本文件的差额补偿 max(0.1 s, 处理耗时) 稳定在 10 Hz;处理太慢时自动降频而不堆积延迟

处理耗时超过 0.1 s 时 remaining 变负、不睡,循环以「能跑多快跑多快」运行------这也是对的:此时任何 sleep 都只会让链路更滞后。

4.4 三个具体缺陷

(1) main() -> None 却 return 1,而且返回值被丢弃。

python

python 复制代码
def main() -> None:          # ← 标注说没有返回值
    ...
    if not calibration_file.exists():
        print(f"标定文件不存在: {calibration_file}")
        return 1             # ← 实际返回了整数
    ...
if __name__ == "__main__":
    main()                   # ← 没有 SystemExit,返回值直接丢掉

后果很具体:标定文件不存在时,程序打印了错误信息,但进程退出码是 0。 任何用 if errorlevel 1 或 subprocess.run(...).returncode 判断成败的外层脚本,都会认为它「成功执行完毕」。

项目里其他文件写的都是 raise SystemExit(main())------只有这里漏了。一行修掉:

python

ini 复制代码
if __name__ == "__main__":
    raise SystemExit(main())

顺便把签名改成 def main() -> int:。

(2) 分辨率不一致只画在本地画面上,接收端完全不知道。

python

css 复制代码
                if (frame.shape[1], frame.shape[0]) != calibration_size:
                    cv2.putText(frame, f"WARNING resolution ... != calibration ...", ...)

这个检查本身很好------内参矩阵只对特定分辨率成立,换了分辨率继续用,坐标就是错的。但警告只画在本机窗口上:

  • 看画面的人知道;
  • 拿到 CV1 报文的电控不知道------报文里没有任何字段表达「我现在的内参不可信」。

更稳妥的做法是在这种情况下也发无效帧:

python

scss 复制代码
                resolution_ok = (frame.shape[1], frame.shape[0]) == calibration_size
                ...
                        if pose_ok and resolution_ok and int(detected_id) == args.target_id:

或者至少让 valid 降级成一个中间态。「宁可说没有,也不要给出错的」------对下游控制来说,一个错误的位置比没有位置危险得多。

(3) 摄像头打开失败抛异常,串口打开失败友好提示------两种风格。

python

python 复制代码
    camera = cv2.VideoCapture(args.camera, cv2.CAP_DSHOW)
    if not camera.isOpened():
        raise RuntimeError(f"无法打开摄像头 {args.camera}")   # ← 抛异常,回溯退出

    try:
        with open_serial(port, baudrate) as sender:          # ← 在 try 内
            ...
    except serial.SerialException as exc:
        print(f"串口错误:{exc}")                             # ← 友好提示

两种处理都「能用」,但同一段启动流程里出现两种错误处理风格,会让使用者对「失败长什么样」没有预期 。而 raise RuntimeError 在 camera 已经打开之后、try 之外抛出,也不会触发 finally 里的 camera.release()(虽然此时摄像头本来就没打开成功,实际影响有限)。

建议统一成「打印 + 返回非零退出码」,与项目其他文件的风格一致。

另外两个小点:

  • detector.detectMarkers(frame) 传的是彩色帧 。ArUco 接受彩色输入,但内部仍要处理三通道数据;task2 的两个脚本都是先转灰度再检测。统一成灰度可以省一点时间,也让两条路径的输入一致。
  • 没有设置 cv2.CAP_PROP_BUFFERSIZE。 这是实时取帧的经典问题:摄像头驱动和 OpenCV 内部会缓冲若干帧,如果处理速度低于采集速度,你读到的可能是「几百毫秒之前」的画面------位姿是对的,但它描述的是过去。 对实车而言这是纯延迟。加一行通常能明显改善:

python

c 复制代码
    camera.set(cv2.CAP_PROP_BUFFERSIZE, 1)

5. config.json 里那 4 个从没被读取过的键

配置文件一共 8 个键:

json

json 复制代码
{
  "pattern_size": [9, 6],
  "square_size_m": 0.025,
  "tag_size_m": 0.163,
  "expected_width": 1280,
  "expected_height": 720,
  "serial_port": "COM11",
  "serial_baudrate": 115200,
  "calibration_file": "../task2_apriltag/output/camera_params.npz"
}

我把整个仓库 grep 了一遍,实际被代码读取的只有 4 个:

键 是否被读取 读取位置
serial_port ✅ args.port or cfg.get("serial_port", "COM11")
serial_baudrate ✅ int(cfg.get("serial_baudrate", 115200))
calibration_file ✅ 路径解析与存在性检查
tag_size_m ✅ float(cfg["tag_size_m"])
pattern_size ❌ 无人读取(标定在 task2 里做)
square_size_m ❌ 无人读取
expected_width ❌ 无人读取
expected_height ❌ 无人读取

后四个是"死键"。 其中 expected_width / expected_height 尤其值得说:它们的职能已经被更好的实现取代了------代码不再读配置里的期望分辨率,而是直接从标定文件里读:

python

c 复制代码
    calibration_size = (
        int(calibration["image_width"]),
        int(calibration["image_height"]),
    )

这个改法是对的 (期望值应该来自「参数的来源」,而不是另一份手写副本),但它留下了配置里的死键。而 square_size_m: 0.025 与 task2 标定时用的 22 mm 不一致,也没有任何人发现------因为没人读它。

一个没被使用的配置项不会报错,只会腐烂。 它最坏的影响不是占了几行,而是让读者以为某个值在被使用 。如果一个评审人看到 square_size_m: 0.025,他很可能会问「标定用的是 22 mm 还是 25 mm」------而这个问题在代码里其实没有答案,因为没人用它。

建议:删掉这 4 个键(手抄副本),或者在注释/README 里说明「保留供将来使用」。 两种都可以,唯一不该做的是放着不管。

顺带一个同类问题:requirements.txt 里有 15 个包被精确锁版本,其中 matplotlib(及其 6 个传递依赖)和 PyYAML 都没有被任何代码 import 。这让它看起来像一份 pip freeze 的输出而不是一份依赖声明。依赖清单里出现未使用的包,会让「这个项目需要什么」变得模糊------尤其是当有人想在新机器上搭环境、想减少安装体积的时候。

6. 本篇的核心:用 3002 条真实报文验一遍

前面所有的结论都是「读代码读出来的」,但 task3_serial/logs/serial_receive.log 里躺着 3002 条真实接收到的报文 。这比任何代码分析都有说服力------只要逐行重算校验和,就能把「我认为它对」变成「它确实对」。

bash

bash 复制代码
python blog/tools/verify_serial_log.py task3_serial/logs/serial_receive.log

这个脚本做四件事:逐行重算 XOR、识别会话边界、统计 seq 连续性、分析有效帧的分布。

6.1 XOR 校验:3002 / 3002 全部通过

代码块

yaml 复制代码
【1】XOR 校验(逐行重算)
  通过: 3002 / 3002
  全部报文校验和与重算结果一致 ✅

这是整个项目最有力的一份自证材料。 它的含义是:从 Python 组帧、经过虚拟串口对、到串口助手落盘,这 3002 条报文没有任何一个字节被破坏或错位。 如果 XOR 范围写错、如果 CRLF 少了一个字节、如果串口参数不匹配导致丢字节,这一步立刻会暴露出来。

而这正是我在本系列第一篇里建议过的「<30 行的校验脚本」。 现在它存在了,而 test_log.md 里那句 [x] XOR 校验正确 也就从「主观判断」变成了「可复现的数字」。

6.2 日志是 5 段会话拼接的,seq 每次从 0 重来

代码块

bash 复制代码
【3】会话划分(seq 或 t_ms 回退即视为新会话)

--- 会话 1(日志第 2--15 行)---     14 帧,   seq 1133 → 1146
--- 会话 2(第 16--975 行)---       960 帧,  seq 0 → 959    32.0 s
--- 会话 3(第 976--2005 行)---     1030 帧, seq 0 → 1029   34.4 s
--- 会话 4(第 2006--2425 行)---    420 帧,  seq 0 → 419    14.0 s
--- 会话 5(第 2426--3003 行)---    578 帧,  seq 0 → 577    19.3 s

这件事必须写清楚,否则会产生两个误解:

  1. 跨会话看,seq 是"回退"了 4 次的 (每次程序重启都从 0 开始)。如果评审人写个脚本检查「seq 是否严格递增」,他会得到「发现 4 处回退」的结论。test_log.md 里那句 [x] seq 持续递增 只在单个会话内成立。
  2. 会话 1 只有 14 帧、序号从 1133 开始------那是从某次运行中间截取的一段。

建议:在日志里插入会话分隔标记 (比如每次启动时写一行 === session start ===),或者在 test_log.md 里注明「日志含 5 段会话,每段 seq 从 0 开始」。这样自动化校验脚本才能正确切分,而不是报出一堆假警报。

好消息是:在每个会话内部,seq 的跳变总数是 0。

代码块

bash 复制代码
【4】总览
  seq 跳变总数  : 0

778 秒的运行时间内,没有丢过一包。 这直接验证了「115200 波特率对这个报文长度和频率是够用的」------报文字节数约 57--70,按 30 Hz 算,一秒约 1800--2100 字节,而 115200/8N1 的理论吞吐约 11520 字节/秒,占用率不到 20% 。

6.3 一个需要你确认的问题:日志的发送频率是 29.9 Hz,而代码写的是 10 Hz

代码块

yaml 复制代码
【6】发送间隔分布(判断实际发送频率)
  样本数        : 2997
  平均 / 中位   : 33.4 ms / 32 ms   → 约 29.9 Hz
  最小 / 最大   : 10 ms / 96 ms
  P5 / P95      : 30 ms / 48 ms

平均间隔 33.4 ms,也就是约 30 Hz------正好等于摄像头的帧率。 而当前代码里是:

python

ini 复制代码
    send_period = 0.1     # 0.1 s → 10 Hz

两者对不上,而且不是小差别(3 倍)。 如果节流生效,t_ms 的增量应该稳定在 100 ms 附近,不可能出现平均 33.4 ms、中位 32 ms 这样的分布。

可能的解释只有两种:

  1. 这份日志是早期版本的代码跑出来的 (那时可能是每帧都发,没有 send_period 节流);
  2. 日志来自另一个脚本,或者节流逻辑当时还没写完。

无论是哪种,结论都是一样的:日志证据与当前代码不同源。 这和第三部分发现的「任务二的位姿数据来自另一条解算路径」是同一类问题------

交付物由两份不同的东西组成时,验证必须覆盖真正交付的那一份。

建议:用当前代码重跑一次,重新保存日志。 重跑之后你会得到两个额外收益:

  • 日志里的 t_ms 间隔约 100 ms,直接证明节流实现了;
  • 报文总数会明显减少(30 Hz → 10 Hz),对 115200 的占用率进一步下降。
markdown 复制代码
*结论:日志确实来自旧版本(无节流)。而带节流的那 6 行只存在于工作区,git 里一次都没有,也从未产生过任何日志。**
markdown 复制代码
```
      无节流                                带节流
      (620 条 / 20.0 s)                     (201 条 / 20.1 s)

 583  ███                               194  ███
      ███  24                                ███   4
   9  ███  ███   2                        2  ███  ███
 ──┴────┴────┴────┴──                  ──┴────┴────┴────┴──
  20 30  35  40  50                      80 95 105 120
      间隔 (ms)                              间隔 (ms)
```

完整分布:

| 间隔区间 | 无节流 | 带节流 |
| --- | --- | --- |
| < 20 ms | 1 | 0 |
| 20--30 ms | 9 | 0 |
| **30--35 ms** | **583** | 0 |
| 35--40 ms | 24 | 0 |
| 40--50 ms | 2 | 0 |
| 80--95 ms | 0 | 2 |
| **95--105 ms** | **0** | **194** |
| 105--120 ms | 0 | 4 |

**两个分布完全不重叠。** 无节流集中在 30--35 ms,带节流集中在 95--105 ms------**改变非常直观,不需要看统计量也能一眼分出来。**

![节流生效前后](figures/throttle-before-after.png)

*同一个采集脚本,只切换那 3 行节流代码,各跑 20 秒。间隔分布从集中在 32 ms 整体平移到 100 ms,频率 30 Hz → 10 Hz。(左图复现了仓库里那份真实旧日志的 30 Hz,说明两次测量的方法一致、可比。)*
markdown 复制代码
```
      无节流                                带节流
      (620 条 / 20.0 s)                     (201 条 / 20.1 s)

 583  ███                               194  ███
      ███  24                                ███   4
   9  ███  ███   2                        2  ███  ███
 ──┴────┴────┴────┴──                  ──┴────┴────┴────┴──
  20 30  35  40  50                      80 95 105 120
      间隔 (ms)                              间隔 (ms)
```

完整分布:

| 间隔区间 | 无节流 | 带节流 |
| --- | --- | --- |
| < 20 ms | 1 | 0 |
| 20--30 ms | 9 | 0 |
| **30--35 ms** | **583** | 0 |
| 35--40 ms | 24 | 0 |
| 40--50 ms | 2 | 0 |
| 80--95 ms | 0 | 2 |
| **95--105 ms** | **0** | **194** |
| 105--120 ms | 0 | 4 |

**两个分布完全不重叠。** 无节流集中在 30--35 ms,带节流集中在 95--105 ms------**改变非常直观,不需要看统计量也能一眼分出来。**

![节流生效前后](figures/throttle-before-after.png)

*同一个采集脚本,只切换那 3 行节流代码,各跑 20 秒。间隔分布从集中在 32 ms 整体平移到 100 ms,频率 30 Hz → 10 Hz。(左图复现了仓库里那份真实旧日志的 30 Hz,说明两次测量的方法一致、可比。)*

6.4 有效帧的分布:检测在明显抖动

代码块

yaml 复制代码
【4】总览
  有效帧总数    : 344
  无效帧总数    : 2658
  valid 切换总数: 96

【5】有效帧的连续段长度(衡量检测稳定性)
  连续有效段数  : 48
  每段长度(帧)  : 平均 7.2,最长 96,最短 1
  长度 ≤3 帧的段: 35 / 48  (73 % 的有效段只持续 3 帧以内)

344 / 3002,有效帧只占 11.5%。 这个比例本身不奇怪(测试时 Tag 大部分时间不在画面里,会话 1、2、4 甚至完全没有 Tag),但有效帧的"碎片化"很值得注意:

  • 48 段连续有效,平均每段只有 7.2 帧;
  • 73% 的段只持续 3 帧以内------按 33 ms 的间隔算,也就是 0.1 秒左右;
  • 段长度分布高度不均:46, 6, 49, 1, 5, 24, 1, 1, 1, 2, 1, 3, 2, 27, 96 ...

这说明什么? 说明 Tag 在画面里的时候,检测并不是稳定输出,而是在「检出几帧 → 丢一两帧 → 又检出」之间跳。对离线分析这没什么,但对实车是大问题:

  • 电控侧收到的 valid 会在 0 和 1 之间高频翻转;
  • 一个「跟着装甲板转」的控制逻辑,会因为目标时有时无而不断启停;
  • 更糟的是,valid=0 的帧把坐标清零了,所以电控看到的是一串「有位置 → 零 → 有位置」的锯齿。

这正好解释了为什么真实的视觉代码几乎一定会加时间维度的滤波。 最轻量的做法是对最近 N 帧做滑动平均或指数平滑(EMA):

python

ini 复制代码
    # 对连续有效帧做 EMA,抑制单帧抖动
    if valid == 1:
        if ema is None:
            ema = np.array([x_mm, y_mm, z_mm], dtype=np.float64)
        else:
            ema = 0.3 * np.array([x_mm, y_mm, z_mm]) + 0.7 * ema
        x_mm, y_mm, z_mm = ema

更值得做的是"丢失容忍" :允许连续若干帧丢失仍保持 valid=1(用上一帧位置填充或预测),只有连续丢失超过阈值才真正置 valid=0。这样电控看到的是一条连贯的轨迹,而不是锯齿。

这一条建议在考核里大概不是必须的,但它是由你自己的日志数据推出来的------这比泛泛地说「可以加个滤波」有价值得多。

6.5 坐标范围:一个能直接说明「z ≠ 直线距离」的实测例子

代码块

yaml 复制代码
【7】有效帧的坐标范围(单位 mm)
  x_mm  :    -242.1 ~     -79.1
  y_mm  :    -275.4 ~     -78.7
  z_mm  :     573.6 ~    1037.7
  出现的 tag_id : [0]
  偏离光轴最远的一帧: x=-136.6 y=-275.4 z=1028.3 → 直线距离 1073.3 mm (比 z 大 45.0 mm)

三件事:

(1) 单位确认无误。 z_mm 在 574--1038 之间,对应 0.57--1.04 m------正是手持 Tag 在摄像头前的合理距离。如果单位错成米,这里会是 0.57 这种数字。

(2) 坐标确实是负的、离轴的。 x 全为负、y 也全为负,说明 Tag 一直在画面中心的左上方。这也顺便解释了为什么 distance 和 z 会差这么多。

(3) 「z 不等于距离」有了真实数字:偏离光轴最远的那一帧,z = 1028.3 mm,而直线距离是 1073.3 mm------差了 45 mm(4.4%)。

如果实车按 z 判断"距离装甲板多远",在 Tag 偏离画面中心 30 厘米的情况下,就会低估 4.5 厘米 ------对弹道解算来说这个量级已经不小了。这一段正好把第一篇里那句「z 是深度,不等于直线距离」从概念变成了自己数据里的一个具体数字。

6.6 旋转向量:一个必须解释清楚的数字

代码块

yaml 复制代码
【8】有效帧的旋转向量范围(单位 rad)
  rx : -3.1401 ~ +3.1388
  ry : -2.6302 ~ +2.7357
  rz : -0.5936 ~ +0.6501
  |rvec|: 2.3536 ~ 3.1415 rad  = 134.8° ~ 180.0°

第一次看到这个结果会以为位姿解错了:一个大致对着摄像头的 Tag,怎么会解出 134°--180° 的旋转?

我用一段合成实验验证了它------用 OpenCV 自己生成一张 tag36h11 的图(正对相机、图像中摆正),检测出角点,再用任务三的物点模板解位姿:

| 模板 | rvec | |rvec| | tvec | 与理论 z=0.2173 m 的偏差 | | --------- | -------------------------- | ---------- | -------------------------------- | ------------------ | | 任务三模板 | (-3.1403, 0.0000, -0.0020) | 179.9° | (-0.0001, -0.0001, +0.2177 ) | 0.36 mm ✅ | | 图像顺序模板 | (-2810.55, 0, -1.76) | 161033° | (0, 0, 0) | 217.33 mm ❌ |

bash

bash 复制代码
python blog/tools/check_aruco_rvec_convention.py

第一行是关键结论:一个正对相机的 Tag,在任务三的模板下解出的 rvec 就是约 180°(绕 X 轴)。 对应的旋转矩阵是

代码块

lua 复制代码
R = [[ 1,  0,  0],
     [ 0, -1,  0],
     [ 0,  0, -1]]

这不是 bug,而是**「物点顺序 + ArUco 角点约定」共同决定的坐标系定义**:在这个约定下,Tag 的 Z 轴指向相机,而相机坐标系的 Z 轴指向场景内部------两者相差一个 180° 的翻转。而 tvec 的误差只有 0.36 mm,说明位姿解算本身是准的。

所以看到 rx ≈ ±3.14 不要慌,先确认你的物点模板是哪一套。 这也解释了日志里 |rvec| 为什么全落在 134.8°--180°:Tag 大致正对相机,只在 ±45° 范围内摆动------这个分布恰恰说明解算是稳定的 (如果解算在抖动,|rvec| 会散得更开)。不要拿 rx/ry/rz 在电控侧直接做姿态闭环。 因为它会在 ±3.14 处跳变------ 实测 29.8% 的相邻帧都在变号,最大一次数字跳了 6.28(也就是一整圈), 而 Tag 实际只动了 1.94°

第二行同样重要:模板顺序错了不会"安静地错",而是直接崩掉。 我原本以为顺序错配会得到一个"看起来合理但旋转了 180°"的位姿,实测否掉了这个猜测 :换一种物点顺序后,tvec 直接退化成 (0, 0, 0),rvec 变成天文数字。

原因在于 SOLVEPNP_IPPE_SQUARE 对点序有硬性要求 ------它就是为"正方形四点的标准顺序"设计的,喂给它别的顺序,解析解直接退化。这是个好消息:这类错误会以"一看就不对"的方式暴露,而不是伪装成正常结果。

【待填:你在实测时看到 rx ≈ 3.14 有没有困惑过?如果有,这一段就是本篇最有共鸣的部分------它把"看起来像 bug 的正常行为"讲清楚了。】

6.7 test_log.md 的 8 项声明,逐项复核

test_log.md 里列了 8 条 [x]。既然有了日志和测试脚本,就可以把它们逐条变成数字:

声明 复核方式 结果
Tag 出现时 valid=1 日志会话 3、5 中存在有效帧 ✅ 344 帧
Tag 消失时 valid=0 会话 1、2、4 全部为 valid=0 ✅ 2658 帧
Tag 再次出现后恢复 valid=1 96 次 valid 切换,含 0→1→0→1 的完整往复 ✅
seq 持续递增 会话内跳变数 ✅ 0 次(跨会话会从 0 重启,需注明)
坐标单位为 mm z_mm 落在 573.6--1037.7 ✅ 与 0.57--1.04 m 吻合
旋转向量单位为 rad ` rvec ` 落在 2.35--3.14 ✅ 与"绕 X 轴约 180°"的预期一致
XOR 校验正确 3002 条逐行重算 ✅ 3002/3002
CRLF 正确 字节级组帧测试 ✅ 0D 0A(日志本身无法验证,需靠测试)

7 项能独立复现,1 项(CRLF)只能靠字节级测试确认------因为串口助手是按行显示文本的,CRLF 在落盘时已经变成了换行,日志里看不到它。"要求真实字节"这句话,最终只有组帧测试能证明。

建议把 test_log.md 从"打勾清单"升级成"带数字的记录" ,例如:

代码块

css 复制代码
- [x] XOR 校验正确:3002/3002 条逐行重算一致(verify_serial_log.py)
- [x] CRLF 正确:组帧测试断言末尾两字节为 0D 0A(test_cv1_packet.py)
- [x] seq 递增:单会话内 0 次跳变;日志共 5 段会话,每段从 0 开始

同样一个勾,后者能让读者自己去复现。

7. 小结与改进清单

task3_serial/ 做对的三件事:

  1. 协议层与硬件解耦 (cv1_protocol.py 不 import cv2),使契约可以被独立测试------这是全项目可测试性最高的一处设计;
  2. 所有失败路径汇聚到"发无效帧"这一个出口,让链路静默变得不可能;
  3. 相对路径以 __file__ 为基准,从任何目录都能运行,明显优于其它任务的硬编码相对路径。

按性价比排序的改进清单:

优先级 改动 收益
高 用当前代码重跑并重新保存日志 让日志证据与交付代码同源(33 ms → 100 ms 的对比本身就是证据)
高 底部改成 raise SystemExit(main()),并把签名改为 -> int 修掉「失败也返回退出码 0」
高 分辨率不一致时让 valid=0(而不只是画个本地警告) 避免把不可信的坐标发给电控
高 对有效位姿做 EMA 平滑 + 丢失容忍 直接解决日志暴露的 73% 碎片化问题
中 删掉 config.json 的 4 个死键,并清理 requirements.txt 的未用依赖 消除「以为它在生效」的错觉
中 加 cv2.CAP_PROP_BUFFERSIZE = 1 降低实时取帧的陈旧帧延迟
中 统一启动阶段的错误处理风格(打印 + 非零退出码) 使用者对「失败长什么样」有稳定预期
中 日志里加会话分隔标记 让自动化校验能正确切分 5 段会话
低 xor_checksum 增加对完整报文的兼容;补一个 parse_packet() 组帧与解帧成对,协议模块自洽
低 detectMarkers 前转灰度;with np.load(...) 与 task2 一致、资源管理更规范

8. 系列收尾

四篇拆下来,这个项目的形状其实很清晰:

部分 文件 最值得讲的一件事 最该补的一件事
一 opencv_basics/(553 行) cv2.subtract 的饱和语义、minAreaRect 宽高归一化 把阈值集中到配置文件
二 detect_video.py(227 行) 计时窗口把 capture.read() 包了进去 拆开 decode / detect 两段计时
三 task2_apriltag/(433 行) 仓库里存在两套位姿解算路径 统一路径,让验证覆盖交付
四 task3_serial/(209 行) 3002 条报文 XOR 全通过 重跑日志、加时间滤波

如果把四篇压成一句话:

这个项目的代码质量在"检测与协议"上明显高于"验证与文档"。 协议层能被独立测试、能通过 3002 条报文的自证;而文档里出现了「solvePnP」「10 Hz」这类与代码不符的描述,位姿实验缺少真值对照,日志与代码不同源。 代码是可用的,需要补的是"让证据和代码对齐"这一环------而这一环恰恰是评阅人最容易检查、也最能体现工程素养的地方。

相关推荐
miofly1 小时前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
开源·go·github
有同事要进步2 小时前
如何在github或者是gitlab上创建空文件
gitlab·github
晨间晓3 小时前
图片到底偏什么颜色?用华为云码道开发 OpenCV 色彩偏移分析工具
javascript·opencv·华为云
晨间晓3 小时前
图片是不是过曝了?用华为云码道开发 OpenCV 过曝区域检测工具
人工智能·opencv·华为云
miofly3 小时前
GitHub 日榜趋势速报 | 2026-10-16
开源·github
高频因子挖掘机4 小时前
量化回测应该保存不复权数据吗?从数据口径到收益计算的工程实践
后端·github·api
高频因子挖掘机5 小时前
实时行情为什么不应该逐只股票请求?批量查询与标的池管理实践
后端·github·api
再__努力1点15 小时前
OpenCV 5 vs OpenCV 4:一次讲清所有差异
人工智能·opencv·计算机视觉
用户72737441388416 小时前
JSON 转义字符到底该怎么读:几种常见场景
github