O30urdf标定 fix
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
schema_version: 1
|
||||
reference_view: front
|
||||
cameras:
|
||||
front:
|
||||
serial_number: DB2163742
|
||||
width: 1624
|
||||
height: 1240
|
||||
intrinsics_sha256: 5752443dfe64fb47440151d793544ec3a996891b03fa387459a7ac8c2328dc07
|
||||
side:
|
||||
serial_number: DB2163749
|
||||
width: 1624
|
||||
height: 1240
|
||||
intrinsics_sha256: 25ca0a3f68ccfd1f85d1bdeb9149052bad00c4d4b1f5a88a438a4a143e07e655
|
||||
top:
|
||||
serial_number: DB2163739
|
||||
width: 1624
|
||||
height: 1240
|
||||
intrinsics_sha256: 11cca902dc5493c92d1191cda4d25a30facbb5947428e7141c3ebfe078500446
|
||||
front_from_view:
|
||||
front:
|
||||
translation_xyz_m:
|
||||
- 0.0
|
||||
- 0.0
|
||||
- 0.0
|
||||
quaternion_xyzw:
|
||||
- 0.0
|
||||
- 0.0
|
||||
- 0.0
|
||||
- 1.0
|
||||
side:
|
||||
translation_xyz_m:
|
||||
- -0.8080948548192018
|
||||
- -0.008581421683932288
|
||||
- 1.0532250983464067
|
||||
quaternion_xyzw:
|
||||
- -0.021327054698668555
|
||||
- 0.6912906965521914
|
||||
- 0.01197673364064859
|
||||
- 0.7221626461189797
|
||||
top:
|
||||
translation_xyz_m:
|
||||
- 0.04741691749425549
|
||||
- -0.5804548270182806
|
||||
- 1.0631239748696568
|
||||
quaternion_xyzw:
|
||||
- -0.7015214400964395
|
||||
- -0.04845091102701706
|
||||
- -0.028754624245378058
|
||||
- 0.710417729149672
|
||||
quality:
|
||||
passed: true
|
||||
reprojection_rms_px: 1.0243965778937039
|
||||
maximum_rotation_repeatability_deg: 0.07214266243246935
|
||||
maximum_translation_repeatability_m: 0.0008227910293034463
|
||||
front_side_captures: 15
|
||||
front_top_captures: 15
|
||||
front_side_candidates: 15
|
||||
front_top_candidates: 15
|
||||
front_side_rejected: 0
|
||||
front_top_rejected: 0
|
||||
@@ -2,11 +2,43 @@
|
||||
|
||||
入口为 `calibrate_hand --config src/linkerhand_calibration/config/o30_right_product.yaml`。
|
||||
Profile 为 `O30/right/o30_right_18/v1`,20 个关节全部主动;原始 URDF 与外部 SDK 不被标定器改写。
|
||||
当前产品配置绑定 2026-09-20 重新采集的 `config/o30_three_camera_extrinsics_20260920_123651.yaml`。
|
||||
该外参的 SHA256 为 `e21d21eaaf198d4daba44a44e0aa3bc46a86376213b84c3ddd30ae571714c701`;
|
||||
当前产品配置绑定 2026-09-22 重新采集的 `config/o30_three_camera_extrinsics_20260922_101452.yaml`。
|
||||
该外参的 SHA256 为 `d28d3e8771f5bc20d4ca6c9bfd11977f26fefb8478d125a113c013fb7a208120`;
|
||||
三相机序列号及内参指纹均与现有文件一致。
|
||||
软件测试包含合成数据和已记录的真实图像回归,不能作为整手现场精度报告。
|
||||
|
||||
## 2026-09-22:从头按每任务两轮训练+一轮验证采集
|
||||
|
||||
必须显式选择 `taskwise_2_plus_1`;不带参数的旧 `fixed` 仍是三轮训练+一轮验证。
|
||||
新策略按每个任务连续完成训练 1、训练 2、独立验证,再进入下一个任务,
|
||||
不先扫完整手、不自动补第三轮训练。内部验证轮编号仍为 3,不能当作训练数据。
|
||||
17 个任务共 108 个方向/分段扫描单元(旧 fixed 为 144 个),不含准备与有限辅助观测动作。
|
||||
|
||||
```bash
|
||||
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||
source /opt/ros/jazzy/setup.bash
|
||||
source install/setup.bash
|
||||
export ROS_DOMAIN_ID=99
|
||||
ros2 run linkerhand_calibration calibrate_hand \
|
||||
--config src/linkerhand_calibration/config/o30_right_product.yaml \
|
||||
--training-policy taskwise_2_plus_1 \
|
||||
--preparation-policy occlusion_aware_trajectory_v3 \
|
||||
--no-resume
|
||||
```
|
||||
|
||||
先加 `--validate-only` 可只检查配置而不启动硬件。实机运行前关闭 MVS 的相机连接,
|
||||
确保只有一个控制者,保留安全运动空间。新计划/调度版本与旧断点隔离;相机已移动,
|
||||
本次应使用 `--no-resume`,不能沿用旧几何证据。
|
||||
|
||||
上面启用已有 v3 有界辅助观测;辅助运动不是额外训练轮。若本任务仍不可观测,
|
||||
明确暂停而不跨任务延后或强选候选;两轮数据不能通过最终验收时也不会发布产物。
|
||||
完整训练拟合、独立图像验证以及 JSON→URDF 一致性检查仍保留。
|
||||
|
||||
**现场限制:**本次 thumb roll、侧面小指 MCP/PIP 的单次往返诊断通过,
|
||||
小指 DIP 原始采集完成但姿态双解仍无法区分。新策略的完整实机 2+1 尚未验收,
|
||||
以上命令不是“一次跑通”的保证。详见 [本次稳定性分析](O30_STABILITY_REVIEW_20260922.md)。
|
||||
下文关于四轮和 144 单元的描述指旧 fixed 策略。
|
||||
|
||||
## 正常摆放与固定基准
|
||||
|
||||
O30 使用 `acquisition.fixed_reference_mode: stationary_image`。机械手可正常摆放,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# O30 稳定性分析与实测:2026-09-22
|
||||
|
||||
## 结论与边界
|
||||
|
||||
新外参已绑定产品配置;相机占用已解除。当前问题不能统一归因为 Tag 检测不稳定。
|
||||
本次已修复诊断覆盖统计和压缩证据读取,并实现逐任务固定两轮训练加一轮独立验证。
|
||||
小指 DIP 在两次现场准备中均无法区分运动图像候选,是尚未解决的正式标定阻塞。
|
||||
未发布本批实机标定 JSON/URDF,没有完成整手连续标定或正式精度验收。
|
||||
|
||||
## 实机证据
|
||||
|
||||
会话均在工作区 `calibration_output/O30_RIGHT_001/`,历史原始证据未改写。
|
||||
|
||||
| 会话 | 任务 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| `20260922_104148` | 正面 thumb roll | 相机被 MVS 占用,access denied;未开始扫描 |
|
||||
| `20260922_104415` | 正面 thumb roll | 单轮往返完整完成,各方向第一次通过,428/426 帧,无暂停重扫 |
|
||||
| `20260922_104609` | 侧面小指 MCP/PIP/DIP | MCP/PIP 往返通过;DIP 准备双解未消除,诊断覆盖统计又误报零帧,重扫后暂停 |
|
||||
| `20260922_105323` | 修复统计后的侧面小指 | 六个方向均一次完成、无需重扫;MCP/PIP 姿态覆盖通过,DIP 只完成原始观测采集 |
|
||||
|
||||
最新会话有效原始帧(正向/反向):MCP 428/429、PIP 429/422、DIP 430/423。
|
||||
三个准备窗口各有 129 帧;DIP 原因是 `image_motion_families_not_distinguishable`。
|
||||
报告明确 `accuracy_validated: false`、`publication_allowed: false`,DIP 无可信零位。
|
||||
不能把 `DIAGNOSTIC_COMPLETE` 或原始覆盖通过解释为正式标定通过。
|
||||
本批实机是诊断预设的单轮往返,不是完整 2+1,也没有测试 v3 辅助动作的实际效果。
|
||||
最新会话正常完成安全收尾,检查时没有遗留标定控制进程。
|
||||
|
||||
thumb roll 采集到终态约 96.9 秒,其中准备运动约 64.6 秒、扫描约 28.8 秒、求解约 1.5 秒。
|
||||
说明效率还受准备动作影响,扫描轮数减少 25% 不等于总时长减少 25%。
|
||||
|
||||
## 暂停原因分层
|
||||
|
||||
9 月 21 日最近 18 个状态会话的分类:6 次 MOTION-STALL-303、2 次通信断开、
|
||||
2 次反馈过期、1 次重复控制者、1 次父参考失败、1 次零位失败、5 次启动/节点退出失败。
|
||||
这是历史状态分类,不证明这些问题都在当前版本复现或已经修复。
|
||||
SDK 使用 libcanbus 的直接 USB CANFD 接口,缺少 Linux can0 网卡本身不构成故障证据。
|
||||
|
||||
本次实际分离出三个问题:
|
||||
|
||||
1. 相机资源冲突:MVS 持有设备,关闭连接后恢复采集。
|
||||
2. 诊断统计缺陷:临时覆盖记录只带反馈、不带实际指令;O30 按指令域检查覆盖,
|
||||
因而在没有可信姿态、只保留原始图像时误报零帧。现使用原始同步指令填入统计,
|
||||
不制造姿态、不把反馈冒充指令,不改变正式发布门限。
|
||||
3. 几何不可观测:DIP 标签持续可见、原始运动覆盖充分,但两组运动模型仍不能区分。
|
||||
“正对相机、检测清楚”和“转轴/零位可唯一测量”不是同一件事。
|
||||
|
||||
另外,离线诊断此前把压缩证据索引当原始记录,出现 `zero_joints` 错误;
|
||||
已改用公共证据解码入口,并覆盖损坏压缩包拒绝测试。
|
||||
|
||||
## 为什么不能仅增加帧数
|
||||
|
||||
`software_review_20260922_stability/replay_dip_budget.json` 保存只读离线反事实比较。
|
||||
使用 `104609` 原会话 DIP 准备,恢复原父模型、父参考及时间戳:
|
||||
|
||||
- 原 128 预算选得 127 帧,复现不能区分,候选比较 p=0.01019848。
|
||||
- 同一运动改用 256 预算选得 249 帧,仍不能区分,p=0.01832461。
|
||||
|
||||
这不是新的实机采集,也不是正式发布证据。没有降低原 p=0.01 等门限。
|
||||
结论仅是这段已录运动的加密采样不足以消除双解;下一步应验证有界独立观测
|
||||
和父子几何约束能否提供真正不同的信息,而不是重复采同一轨迹直到偶然通过。
|
||||
是否需要改变标签/视角,应以该验证结果判断;相机若再移动,必须重新绑定外参。
|
||||
|
||||
## 新逐任务策略
|
||||
|
||||
`taskwise_2_plus_1` 固定每任务训练轮 0、1 和独立验证轮 3,再进入下一任务。
|
||||
共 108 个扫描单元;不补训练轮,不把不可观测任务移到整手末尾。
|
||||
准备模型不足时,已有有限辅助恢复可先执行;仍失败则停止,不能以效率目标代替精度约束。
|
||||
新计划 v4 / 调度 v15 与旧断点隔离。旧 fixed 和 staged 策略保持原语义。
|
||||
正式启动命令见 [O30 标定说明](O30_RIGHT_CALIBRATION.md)。
|
||||
|
||||
## 软件验证与未完成事项
|
||||
|
||||
- 新调度及诊断覆盖组:28 项通过,16.08 秒;包括合成 20 关节正式产物测试。
|
||||
- 合成产物验证两轮训练/独立留出、零位真值、JSON 重建相同 URDF、正式 mapper 和最终图像验收。
|
||||
- 压缩诊断分析:19 项通过,2.24 秒。
|
||||
- 原 quick 契约组中一个旧测试直接读取压缩索引失败,改用公共解码入口后,
|
||||
针对性复查 30 项通过、1 项未选,3.32 秒;不是放宽运行契约。
|
||||
- 新策略加 v3 的 `--validate-only` 通过,仅代表配置及受保护输入一致。
|
||||
|
||||
各组有重复且跨开发版本,不累计成一次全量回归。尚需 DIP 可观测性改进验证、
|
||||
全手实机逐任务 2+1 连续运行及最终文件精度验收;现阶段不能承诺一次跑通。
|
||||
@@ -1,5 +1,14 @@
|
||||
# 标定测试的执行范围
|
||||
|
||||
2026-09-22 严格逐任务 2+1 用 `test_taskwise_two_round.py`:quick 部分检查
|
||||
逐任务顺序、轮间不重复准备、禁止补轮、断点分区及不可观测不跨任务推迟;
|
||||
integration 部分复用正式 O30 夹具,验证 20 关节拟合、独立留出及 JSON 重建相同 URDF。
|
||||
与 `test_diagnostic_quality.py` 共 28 项通过,16.08 秒。诊断压缩证据读取用
|
||||
`test_diagnostic_analysis.py`,19 项通过、2.24 秒。旧准备契约测试直接读取
|
||||
压缩索引导致一次失败,改用公共 `load_jsonl` 后只复查受影响组,30 项通过、
|
||||
1 项未选,3.32 秒。各组不累计为全量通过;实机边界见
|
||||
`O30_STABILITY_REVIEW_20260922.md`。
|
||||
|
||||
辅助侧摆端点离开条件使用 `test_witness_endpoint.py`。真实夹具
|
||||
`o30_witness_terminal_gap.json.gz` 来自 `20260920_183257` 的 226 帧完整侧摆窗口,
|
||||
末尾连续三帧缺命令,仅余两帧有效尾部。真实记录必须继续拒绝;追加的模拟后续帧
|
||||
|
||||
@@ -26,8 +26,8 @@ cameras:
|
||||
artifacts:
|
||||
source_urdf: package://linkerhand_calibration/urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf
|
||||
source_urdf_sha256: 2be8428498ed8c17d7c39dee50e76ece6d362310cf26cd43834b8f4c79021b5b
|
||||
camera_extrinsics: config/o30_three_camera_extrinsics_20260920_123651.yaml
|
||||
camera_extrinsics_sha256: e21d21eaaf198d4daba44a44e0aa3bc46a86376213b84c3ddd30ae571714c701
|
||||
camera_extrinsics: config/o30_three_camera_extrinsics_20260922_101452.yaml
|
||||
camera_extrinsics_sha256: d28d3e8771f5bc20d4ca6c9bfd11977f26fefb8478d125a113c013fb7a208120
|
||||
calibration_config: package://linkerhand_calibration/config/o30_three_camera_calibration.yaml
|
||||
calibration_config_sha256: 95a8fd3299151755ea3b5209b74b9ec9de1c2d07a05d557e9018e742fb66ce30
|
||||
tag_config: package://linkerhand_calibration/config/o30_right_18_tags.yaml
|
||||
|
||||
@@ -14,7 +14,9 @@ FIXED = "fixed"
|
||||
ADAPTIVE = "adaptive_2_to_3"
|
||||
STAGED = "staged_2_to_3"
|
||||
TASKWISE_STAGED = "taskwise_staged_2_to_3"
|
||||
TASKWISE_TWO_ROUND = "taskwise_2_plus_1"
|
||||
STAGED_POLICIES = frozenset((STAGED, TASKWISE_STAGED))
|
||||
PREPARATION_POLICIES = STAGED_POLICIES | {TASKWISE_TWO_ROUND}
|
||||
LEGACY_TRAINING = (0, 1, 2)
|
||||
LEGACY_HOLDOUT = 3
|
||||
|
||||
@@ -69,7 +71,8 @@ class CapturePlan:
|
||||
return next(task for task in self.tasks if task.task_key == key)
|
||||
|
||||
def as_dict(self):
|
||||
version = ("capture_plan_v3" if self.policy == TASKWISE_STAGED else
|
||||
version = ("capture_plan_v4" if self.policy == TASKWISE_TWO_ROUND else
|
||||
"capture_plan_v3" if self.policy == TASKWISE_STAGED else
|
||||
"capture_plan_v2" if self.policy == STAGED else PLAN_VERSION)
|
||||
result = {"version": version, "policy": self.policy,
|
||||
"command_capture_mode": self.command_capture_mode,
|
||||
@@ -88,6 +91,8 @@ class CapturePlan:
|
||||
result["schedule"] = (
|
||||
"first_whole_hand_pass_then_taskwise_second_pass_with_immediate_supplement_then_validation"
|
||||
)
|
||||
if self.policy == TASKWISE_TWO_ROUND:
|
||||
result["schedule"] = "each_task_two_training_rounds_then_independent_validation"
|
||||
return result
|
||||
|
||||
@property
|
||||
@@ -109,7 +114,7 @@ def task_partition(profile, task=None, *, joint=None):
|
||||
if joint is not None:
|
||||
task = task_for_joint(profile, joint)
|
||||
key = getattr(task, "key", task)
|
||||
default = (LEGACY_TRAINING[:2] if profile.quality.training_policy in STAGED_POLICIES
|
||||
default = (LEGACY_TRAINING[:2] if profile.quality.training_policy in PREPARATION_POLICIES
|
||||
else profile.quality.training_cycles)
|
||||
training = tuple(profile.quality.task_training_cycles.get(key, default))
|
||||
holdout = profile.quality.holdout_cycle
|
||||
@@ -128,6 +133,10 @@ def select_task_training(profile, task_key, cycles):
|
||||
raise ValueError("capture_plan_unknown_task")
|
||||
if cycles not in (LEGACY_TRAINING, LEGACY_TRAINING[:2]):
|
||||
raise ValueError("capture_plan_invalid_training_partition")
|
||||
if profile.quality.training_policy == TASKWISE_TWO_ROUND:
|
||||
if cycles != LEGACY_TRAINING[:2]:
|
||||
raise ValueError("taskwise_two_round_cannot_add_training")
|
||||
return profile
|
||||
if (cycles != tuple(profile.quality.training_cycles)
|
||||
and profile.quality.training_policy not in {ADAPTIVE, *STAGED_POLICIES}):
|
||||
raise ValueError("capture_plan_fixed_training_cannot_change")
|
||||
@@ -136,17 +145,22 @@ def select_task_training(profile, task_key, cycles):
|
||||
|
||||
|
||||
def configure_training(profile, policy):
|
||||
if policy not in {FIXED, ADAPTIVE, *STAGED_POLICIES}:
|
||||
if policy not in {FIXED, ADAPTIVE, *STAGED_POLICIES, TASKWISE_TWO_ROUND}:
|
||||
raise ValueError("unknown_training_policy")
|
||||
if profile.artifacts.output_schema_version in {2, 3} and (
|
||||
tuple(profile.quality.training_cycles) != LEGACY_TRAINING
|
||||
or profile.quality.holdout_cycle != LEGACY_HOLDOUT):
|
||||
raise ValueError("production_partition_requires_versioned_training_policy")
|
||||
if policy in {ADAPTIVE, *STAGED_POLICIES} and (profile.artifacts.output_schema_version < 3
|
||||
if policy in {ADAPTIVE, *STAGED_POLICIES, TASKWISE_TWO_ROUND} and (profile.artifacts.output_schema_version < 3
|
||||
or not profile.command_based_release
|
||||
or profile.acquisition.command_capture_mode != "interleaved"
|
||||
or tuple(profile.quality.training_cycles) != LEGACY_TRAINING
|
||||
or profile.quality.holdout_cycle != LEGACY_HOLDOUT):
|
||||
raise ValueError("adaptive_training_requires_measured_interleaved_command_release")
|
||||
# Fixed two-round collection uses the ordinary task-contiguous scheduler.
|
||||
# Bind every task's partition up front; neither runtime nor replay can
|
||||
# supplement it or reinterpret validation cycle 3 as training cycle 2.
|
||||
selections = ({task.key: LEGACY_TRAINING[:2] for task in profile.motion.tasks}
|
||||
if policy == TASKWISE_TWO_ROUND else {})
|
||||
return replace(profile, quality=replace(profile.quality,
|
||||
training_policy=policy, task_training_cycles={}))
|
||||
training_policy=policy, task_training_cycles=selections))
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Mapping
|
||||
|
||||
from ..domain.profile import CalibrationProfile, validate_profile
|
||||
from ..domain.result import CalibrationResult, JointMapping
|
||||
from ..domain.capture_plan import (CapturePlan, ADAPTIVE, STAGED, TASKWISE_STAGED,
|
||||
from ..domain.capture_plan import (CapturePlan, ADAPTIVE, STAGED, TASKWISE_STAGED, TASKWISE_TWO_ROUND,
|
||||
training_cycles)
|
||||
from ..urdf.kinematics import UrdfKinematicModel
|
||||
from .motion_fit import channel_for_joint, fit_profile_motion, joint_input_direction
|
||||
@@ -100,7 +100,7 @@ def build_axis_observations(profile, model, geometry_records, motion, zero_profi
|
||||
parent = zero_profile.phase_parent_joint.get(joint)
|
||||
if parent is not None and parent not in by_joint:
|
||||
if (cycle != plan.task(task.key).holdout
|
||||
and profile.quality.training_policy in {ADAPTIVE, STAGED, TASKWISE_STAGED}):
|
||||
and profile.quality.training_policy in {ADAPTIVE, STAGED, TASKWISE_STAGED, TASKWISE_TWO_ROUND}):
|
||||
continue # No same-cycle parent observation: do not fabricate a geometric sample.
|
||||
raise ValueError("spatial axis order must place phase parents first")
|
||||
task = next(task for task in profile.motion.tasks if joint in task.joints)
|
||||
@@ -184,7 +184,7 @@ def fit_profile_calibration(profile: CalibrationProfile, source_urdf: Path, reco
|
||||
zero = solve_urdf_zero_offsets(source_urdf=source_urdf, measurements=observations,
|
||||
curves=spatial_curves, motor_by_joint={joint: channel_for_joint(profile, joint) for joint in curves},
|
||||
zero_profile=zero_profile, training_cycles=geometry_training, validation_cycle=geometry_holdout,
|
||||
allow_partial_training_cycles=profile.quality.training_policy in {ADAPTIVE, STAGED, TASKWISE_STAGED},
|
||||
allow_partial_training_cycles=profile.quality.training_policy in {ADAPTIVE, STAGED, TASKWISE_STAGED, TASKWISE_TWO_ROUND},
|
||||
frozen_training=None if frozen_training is None else frozen_training["zero"],
|
||||
maximum_offset_rad=math.radians(20), finger_maximum_offset_rad=math.radians(20),
|
||||
maximum_validation_mae_rad=math.radians(1), maximum_validation_p95_rad=math.radians(2),
|
||||
|
||||
@@ -15,6 +15,13 @@ class DiagnosticCapturePreset:
|
||||
|
||||
|
||||
DIAGNOSTIC_CAPTURE_PRESETS = {
|
||||
"o30_pinky_side": DiagnosticCapturePreset(
|
||||
ProfileKey("O30", "right", "o30_right_18", 1),
|
||||
(("pinky_mcp_pitch_side", ("pinky_mcp_pitch",)),
|
||||
("pinky_pip_side", ("pinky_pip",)),
|
||||
("pinky_dip_side", ("pinky_dip",))),
|
||||
label_zh="小指侧面 MCP/PIP/DIP 诊断",
|
||||
),
|
||||
"o30_thumb_roll": DiagnosticCapturePreset(
|
||||
ProfileKey("O30", "right", "o30_right_18", 1),
|
||||
(("thumb_cmc_roll_front", ("thumb_cmc_roll",)),),
|
||||
|
||||
@@ -15,9 +15,9 @@ def preparation_image_budget(profile, *, joint_count=1):
|
||||
from ..core.geometry.tag_pose.production_image_motion import (
|
||||
DENSE_PREPARATION_SAMPLING, image_parameters_for_sampling,
|
||||
)
|
||||
from ..core.domain.capture_plan import STAGED_POLICIES
|
||||
from ..core.domain.capture_plan import PREPARATION_POLICIES
|
||||
dense = (profile.key.model == 'O30'
|
||||
and profile.quality.training_policy in STAGED_POLICIES
|
||||
and profile.quality.training_policy in PREPARATION_POLICIES
|
||||
and bool(profile.motion.preparation_witnesses) and joint_count == 1)
|
||||
return ((256, image_parameters_for_sampling(DENSE_PREPARATION_SAMPLING)) if dense
|
||||
else (128, image_parameters_for_sampling()))
|
||||
@@ -106,9 +106,9 @@ def configure_preparation(profile, policy='occlusion_aware_witness_v2'):
|
||||
if policy not in {'occlusion_aware_witness_v1', 'occlusion_aware_witness_v2',
|
||||
'occlusion_aware_trajectory_v3'}:
|
||||
raise ValueError('unknown_preparation_policy')
|
||||
from ..core.domain.capture_plan import STAGED_POLICIES
|
||||
from ..core.domain.capture_plan import PREPARATION_POLICIES
|
||||
if (profile.key.profile_id != 'O30/right/o30_right_18/v1'
|
||||
or profile.quality.training_policy not in STAGED_POLICIES):
|
||||
or profile.quality.training_policy not in PREPARATION_POLICIES):
|
||||
raise ValueError('preparation_witness_requires_o30_staged_session')
|
||||
from ..core.fitting.motion_fit import channel_for_joint
|
||||
witnesses = {}
|
||||
|
||||
@@ -44,10 +44,10 @@ def validate_executable_profile(profile, source_urdf):
|
||||
if spec.observer_pose_policy not in {"frozen_parent_v1", "rigid_pair_v1",
|
||||
"rigid_pair_trajectory_v3"}:
|
||||
raise ValueError(f"unknown_observer_pose_policy:{name}")
|
||||
from ..core.domain.capture_plan import STAGED_POLICIES
|
||||
from ..core.domain.capture_plan import PREPARATION_POLICIES
|
||||
if is_rigid_observer(spec.observer_pose_policy) and (
|
||||
spec.kind != "relative_rotation" or profile.vision_motion
|
||||
or profile.quality.training_policy not in STAGED_POLICIES):
|
||||
or profile.quality.training_policy not in PREPARATION_POLICIES):
|
||||
raise ValueError(f"rigid_pair_requires_staged_relative_measurement:{name}")
|
||||
if is_rigid_observer(spec.observer_pose_policy) and name not in observer_dependencies:
|
||||
raise ValueError(f"rigid_pair_requires_independent_parent_hinge:{name}")
|
||||
|
||||
@@ -1320,6 +1320,12 @@ class CalibrationCoordinator:
|
||||
|
||||
def _defer_unobservable_task(self, motion, geometry_error):
|
||||
"""Continue independent collection after bounded v3 observation fails."""
|
||||
from ..core.domain.capture_plan import TASKWISE_TWO_ROUND
|
||||
if self.profile.quality.training_policy == TASKWISE_TWO_ROUND:
|
||||
# This policy promises a complete task before the next one.
|
||||
# Existing local observer recovery runs first; an unresolved
|
||||
# task must pause instead of silently changing the visit order.
|
||||
return False
|
||||
from .task_deferral import recoverable_observation_reason
|
||||
from ..profiles.observer_policy import TRAJECTORY_OBSERVER
|
||||
refresh = bool(motion.parent_model_refresh_for)
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
|
||||
from ..core.geometry.tag_pose.motion_evidence import MotionEvidenceFrame, MotionRelation, RoleCandidates
|
||||
from ..core.geometry.tag_pose.types import SquareTagPose
|
||||
from ..storage import atomic_write_json
|
||||
from ..storage import atomic_write_json, load_jsonl
|
||||
from .motion_provenance import source_frame_sha256
|
||||
from .scan_quality import observation_streams
|
||||
|
||||
@@ -231,8 +231,7 @@ def analyze_capture(profile, raw_path: Path, *, output_dir: Path | None = None,
|
||||
output = raw_path.parent if output_dir is None else output_dir.resolve()
|
||||
_check_output_targets(raw_path, output)
|
||||
before = _file_sha256(raw_path)
|
||||
with raw_path.open(encoding="utf-8") as source:
|
||||
rows = [json.loads(line) for line in source if line.strip()]
|
||||
rows = load_jsonl(raw_path)
|
||||
header, stages = _stages(profile, rows)
|
||||
analyzed = []
|
||||
for index, (identity, relations, records) in enumerate(stages):
|
||||
|
||||
@@ -77,7 +77,8 @@ def evaluate_diagnostic_unit(profile: CalibrationProfile, unit: ScanUnit, attemp
|
||||
coverage.append({field: name, "view": view, "task_name": unit.task_key,
|
||||
"cycle": unit.cycle, "direction": unit.direction, "attempt": attempt,
|
||||
"image_stamp_ns": row["image_stamp_ns"], "sample_phase": "sweep",
|
||||
f"feedback_{profile.command.unit}": feedback[task.command_index]})
|
||||
f"feedback_{profile.command.unit}": feedback[task.command_index],
|
||||
f"command_{profile.command.unit}": row["command_vector"][task.command_index]})
|
||||
observed = evaluate_capture_unit(profile, unit, attempt, coverage,
|
||||
first_cycle_spans={}, include_steady=False)
|
||||
return DiagnosticUnitQuality(observed, accepted,
|
||||
|
||||
@@ -16,7 +16,7 @@ from .diagnostic_capture import DiagnosticCapturePlan
|
||||
from ..core.domain.profile import ACQUISITION_POLICY_VERSION
|
||||
from ..core.domain.motion_path import task_segments, scan_identity
|
||||
from ..core.domain.capture_plan import (CapturePlan, ADAPTIVE, STAGED,
|
||||
TASKWISE_STAGED, STAGED_POLICIES, LEGACY_TRAINING, LEGACY_HOLDOUT)
|
||||
TASKWISE_STAGED, TASKWISE_TWO_ROUND, STAGED_POLICIES, LEGACY_TRAINING, LEGACY_HOLDOUT)
|
||||
TRAINING_CYCLES = LEGACY_TRAINING
|
||||
HOLDOUT_CYCLE = LEGACY_HOLDOUT
|
||||
CAPTURE_SCHEDULE_VERSION = "unified_schedule_v7_single_pass_endpoint_images"
|
||||
@@ -29,8 +29,10 @@ IMAGE_REFERENCE_SCHEDULE_VERSION = "unified_schedule_v11_stationary_image_refere
|
||||
ADAPTIVE_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v12_adaptive_training"
|
||||
STAGED_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v13_staged_training"
|
||||
TASKWISE_STAGED_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v14_taskwise_staged_training"
|
||||
TASKWISE_TWO_ROUND_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v15_taskwise_two_round"
|
||||
SUPPORTED_CAPTURE_SCHEDULE_VERSIONS = frozenset((CAPTURE_SCHEDULE_VERSION,
|
||||
STAGED_CAPTURE_SCHEDULE_VERSION, TASKWISE_STAGED_CAPTURE_SCHEDULE_VERSION,
|
||||
TASKWISE_TWO_ROUND_CAPTURE_SCHEDULE_VERSION,
|
||||
ADAPTIVE_CAPTURE_SCHEDULE_VERSION, SEPARATE_CAPTURE_SCHEDULE_VERSION, *(f"{SEGMENTED_CAPTURE_SCHEDULE_VERSION}_{mode}"
|
||||
for mode in ("interleaved", "separate")), *(f"{PATH_CAPTURE_SCHEDULE_VERSION}_{mode}"
|
||||
for mode in ("interleaved", "separate")), *(f"{VISUAL_CAPTURE_SCHEDULE_VERSION}_{mode}"
|
||||
@@ -41,6 +43,8 @@ SUPPORTED_CAPTURE_SCHEDULE_VERSIONS = frozenset((CAPTURE_SCHEDULE_VERSION,
|
||||
|
||||
|
||||
def capture_schedule_version(profile):
|
||||
if profile.quality.training_policy == TASKWISE_TWO_ROUND:
|
||||
return TASKWISE_TWO_ROUND_CAPTURE_SCHEDULE_VERSION
|
||||
if profile.quality.training_policy == STAGED:
|
||||
return STAGED_CAPTURE_SCHEDULE_VERSION
|
||||
if profile.quality.training_policy == TASKWISE_STAGED:
|
||||
|
||||
@@ -324,8 +324,8 @@ def main(args: list[str] | None = None) -> None:
|
||||
parser.add_argument("--commands-disabled", action="store_true")
|
||||
parser.add_argument("--no-resume", action="store_true")
|
||||
parser.add_argument("--training-policy", choices=("fixed", "adaptive_2_to_3", "staged_2_to_3",
|
||||
"taskwise_staged_2_to_3"), default=None,
|
||||
help="试验采集策略:两轮训练,必要时补第三轮;保留独立验证,默认沿用配置")
|
||||
"taskwise_staged_2_to_3", "taskwise_2_plus_1"), default=None,
|
||||
help="taskwise_2_plus_1:每任务连续两轮训练加一轮验证、不补轮;其他策略保留原语义,默认沿用配置")
|
||||
parser.add_argument("--startup-timeout-seconds", type=float, default=None,
|
||||
help="等待节点初始化及设备就绪的秒数(默认 120);大断点逐帧校验可显式延长,不改变运动保护")
|
||||
diagnostic_options = parser.add_mutually_exclusive_group()
|
||||
|
||||
@@ -6,8 +6,8 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from ..core.domain.capture_plan import (ADAPTIVE, STAGED, TASKWISE_STAGED,
|
||||
CapturePlan, configure_training, evidence_digest, select_task_training)
|
||||
from ..core.domain.capture_plan import (ADAPTIVE, STAGED, TASKWISE_STAGED, TASKWISE_TWO_ROUND,
|
||||
CapturePlan, LEGACY_HOLDOUT, configure_training, evidence_digest, select_task_training)
|
||||
from ..core.domain.reference import JointZeroReference
|
||||
from ..core.fitting.training_quality import (TRAINING_DECISION_VERSION, assess_training,
|
||||
training_snapshot)
|
||||
@@ -27,6 +27,11 @@ def resolve_capture_plan(profile, records, *, source_urdf=None, verify_decisions
|
||||
raise ValueError("adaptive_decisions_in_fixed_capture")
|
||||
if headers and "capture_plan" in headers[0] and headers[0]["capture_plan"] != plan.as_dict():
|
||||
raise ValueError("capture_plan_header_changed")
|
||||
if base.quality.training_policy == TASKWISE_TWO_ROUND and any(
|
||||
row.get("kind") in {"joint_sample", "secondary_joint_sample", "scan_unit_complete"}
|
||||
and row.get("cycle") not in (0, 1, LEGACY_HOLDOUT)
|
||||
for row in records):
|
||||
raise ValueError("taskwise_two_round_unexpected_cycle")
|
||||
return base
|
||||
if len(headers) != 1 or headers[0].get("capture_plan") != plan.as_dict():
|
||||
raise ValueError("adaptive_capture_requires_matching_plan_header")
|
||||
|
||||
@@ -24,8 +24,12 @@ from linkerhand_calibration.runtime.image_capture import IMAGE_OBSERVATION_POLIC
|
||||
from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256
|
||||
|
||||
|
||||
def formal_capture(capture, directory, *, adaptive=False):
|
||||
def formal_capture(capture, directory, *, adaptive=False, training_policy=None):
|
||||
profile, source, original, truth = capture
|
||||
if training_policy is not None:
|
||||
if adaptive:
|
||||
raise ValueError("select exactly one training policy")
|
||||
profile = configure_training(profile, training_policy)
|
||||
if adaptive:
|
||||
profile = configure_training(profile, ADAPTIVE)
|
||||
records = deepcopy(original)
|
||||
|
||||
@@ -33,6 +33,40 @@ def mock_image_analysis(monkeypatch, callback=None):
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("corrupt", [False, True])
|
||||
def test_indexed_capture_is_decoded_and_corrupt_archive_is_rejected(tmp_path, monkeypatch, corrupt):
|
||||
from linkerhand_calibration.core.artifacts.evidence_journal import (
|
||||
archive_path, initialize_evidence_journal,
|
||||
)
|
||||
from linkerhand_calibration.storage import append_jsonl_many
|
||||
|
||||
fixture = recorded_fixture("192637")
|
||||
rows = [fixture["session_start"], fixture["camera_model"], *fixture["records"],
|
||||
fixture["recorded_initialization"]]
|
||||
raw = tmp_path / "raw_samples.jsonl"
|
||||
initialize_evidence_journal(raw, threshold_bytes=1)
|
||||
append_jsonl_many(raw, rows)
|
||||
archive = archive_path(raw)
|
||||
if corrupt:
|
||||
data = bytearray(archive.read_bytes())
|
||||
data[0] ^= 1
|
||||
archive.write_bytes(data)
|
||||
before = (raw.read_bytes(), archive.read_bytes())
|
||||
calls = mock_image_analysis(monkeypatch)
|
||||
profile = load_bundled_hand_profile("o6_right_8")
|
||||
if corrupt:
|
||||
with pytest.raises(ValueError):
|
||||
analysis.analyze_capture(profile, raw)
|
||||
assert not calls
|
||||
assert not (tmp_path / "diagnostic_analysis.json").exists()
|
||||
else:
|
||||
report = analysis.analyze_capture(profile, raw)
|
||||
assert report["status"] == "complete"
|
||||
assert len(calls[0][0]) == 120
|
||||
assert report["publication_allowed"] is False
|
||||
assert (raw.read_bytes(), archive.read_bytes()) == before
|
||||
|
||||
|
||||
def test_actual_initialization_images_remain_bound_in_separate_analysis_output(tmp_path, monkeypatch):
|
||||
fixture, _, raw = write_actual_capture(tmp_path)
|
||||
before = raw.read_bytes()
|
||||
|
||||
@@ -14,6 +14,21 @@ from linkerhand_calibration.runtime.engine import CalibrationEngine
|
||||
PACKAGE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_o30_pinky_preset_keeps_dependency_order_and_side_camera():
|
||||
from linkerhand_calibration.runtime.observation_scope import required_observation_views
|
||||
|
||||
profile = load_bundled_hand_profile('o30_right_18')
|
||||
plan = resolve_diagnostic_capture(profile, 'o30_pinky_side')
|
||||
assert plan.task_keys == ('pinky_mcp_pitch_side', 'pinky_pip_side', 'pinky_dip_side')
|
||||
assert required_observation_views(profile, plan) == ('side',)
|
||||
units = CalibrationEngine(profile, diagnostic_capture=plan).scan_units()
|
||||
assert [(u.task_key, u.cycle, u.direction) for u in units] == [
|
||||
(key, 0, direction) for key in plan.task_keys
|
||||
for direction in ('increasing', 'decreasing')]
|
||||
with pytest.raises(ValueError, match='diagnostic_capture_not_for_publication_or_resume'):
|
||||
reject_diagnostic_capture([dict(kind='session_start', diagnostic_capture=plan.as_dict())])
|
||||
|
||||
|
||||
def test_o30_roll_preset_keeps_one_round_and_existing_clearance():
|
||||
profile = load_bundled_hand_profile('o30_right_18')
|
||||
plan = resolve_diagnostic_capture(profile, 'o30_thumb_roll')
|
||||
|
||||
@@ -32,7 +32,7 @@ def raw_frames(profile, unit):
|
||||
return rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"])
|
||||
@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19", "o30_right_18"])
|
||||
def test_raw_corners_cover_motion_without_becoming_measured_joint_angles(layout):
|
||||
profile = load_bundled_hand_profile(layout)
|
||||
unit = CalibrationEngine(profile).scan_units()[0]
|
||||
@@ -101,6 +101,20 @@ def test_visible_tags_and_full_endpoint_span_cannot_hide_a_synchronization_gap()
|
||||
assert timing["maximum_synchronized_gap_seconds"] > timing["maximum_image_gap_seconds"]
|
||||
|
||||
|
||||
def test_command_release_diagnostic_does_not_substitute_encoder_for_command():
|
||||
profile = load_bundled_hand_profile('o30_right_18')
|
||||
unit = next(u for u in CalibrationEngine(profile).scan_units()
|
||||
if u.task_key == 'pinky_dip_side')
|
||||
rows = raw_frames(profile, unit)
|
||||
channel = profile.command.command_index_by_joint['pinky_dip']
|
||||
for row in rows:
|
||||
row['command_vector'][channel] = 127.
|
||||
result = evaluate_diagnostic_unit(profile, unit, 1, rows)
|
||||
assert not result.observations.passed
|
||||
assert any('command_span' in reason for reason in result.observations.failures)
|
||||
assert not result.accepted_poses.passed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["steady", "joint_zero"])
|
||||
def test_non_sweep_diagnostic_images_cannot_fill_missing_sweep_coverage(phase):
|
||||
profile = load_bundled_hand_profile("o6_right_8")
|
||||
|
||||
@@ -127,7 +127,8 @@ def test_live_coordinator_writes_effective_preparation_graph(tmp_path):
|
||||
from runtime_host_fixture import coordinator_fixture
|
||||
host, _ = coordinator_fixture(tmp_path, model='o30', profile_transform=lambda _: dense_profile())
|
||||
try:
|
||||
row = json.loads(host.raw_path.read_text().splitlines()[0])
|
||||
from linkerhand_calibration.storage import load_jsonl
|
||||
row = load_jsonl(host.raw_path)[0]
|
||||
assert row['preparation_contract'] == preparation_contract(host.profile)
|
||||
assert CalibrationEngine(host.profile).resume_compatible(row)
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Fixed task-contiguous collection preserves the existing release boundary."""
|
||||
|
||||
from itertools import groupby
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from linkerhand_calibration.core.domain.capture_plan import (
|
||||
CapturePlan, TASKWISE_TWO_ROUND, configure_training, select_task_training,
|
||||
)
|
||||
from linkerhand_calibration.profiles import load_bundled_hand_profile
|
||||
from linkerhand_calibration.runtime.engine import CalibrationEngine
|
||||
from linkerhand_calibration.runtime.execution import SessionExecution
|
||||
from linkerhand_calibration.runtime.session import CalibrationPhase
|
||||
from linkerhand_calibration.runtime.training import resolve_capture_plan
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile():
|
||||
return configure_training(load_bundled_hand_profile('o30_right_18'), TASKWISE_TWO_ROUND)
|
||||
|
||||
|
||||
def test_every_task_finishes_two_training_rounds_and_validation_before_next(profile):
|
||||
engine = CalibrationEngine(profile)
|
||||
units = engine.scan_units()
|
||||
visits = [key for key, _ in groupby(units, key=lambda u: (u.task_key, u.cycle))]
|
||||
assert visits == [(t.key, cycle) for t in profile.motion.tasks for cycle in (0, 1, 3)]
|
||||
assert len(units) == 108
|
||||
assert CapturePlan.from_profile(profile).as_dict()['version'] == 'capture_plan_v4'
|
||||
assert all(t.training == (0, 1) and t.holdout == 3 for t in CapturePlan.from_profile(profile).tasks)
|
||||
assert engine.capture_schedule_version != CalibrationEngine(configure_training(profile, 'fixed')).capture_schedule_version
|
||||
|
||||
|
||||
def test_round_transition_does_not_repeat_task_exit_or_reference_preparation(profile):
|
||||
execution = SessionExecution(profile)
|
||||
task = profile.motion.tasks[0]
|
||||
execution._entered_task = task.key
|
||||
execution._entered_visit = (task.key, 0)
|
||||
execution.zero_references.update({name: None for name in task.joints})
|
||||
execution.session._unit_index = next(i for i, u in enumerate(execution.session._units)
|
||||
if u.task_key == task.key and u.cycle == 1)
|
||||
execution.session.phase = CalibrationPhase.PREPARE
|
||||
effects = execution._make_effects(profile.command.baseline_values)
|
||||
assert not any(e.phase in {'task_exit', 'stage_return', 'joint_zero', 'zero_approach'} for e in effects)
|
||||
assert execution.session.status().task.cycle == 2
|
||||
assert execution.session.status().task.cycle_count == 3
|
||||
|
||||
|
||||
def test_partition_replay_rejects_added_round_or_changed_header(profile):
|
||||
header = dict(kind='session_start', capture_plan=CapturePlan.from_profile(profile).as_dict())
|
||||
assert resolve_capture_plan(profile, [header]) == profile
|
||||
with pytest.raises(ValueError, match='cannot_add_training'):
|
||||
select_task_training(profile, profile.motion.tasks[0].key, (0, 1, 2))
|
||||
with pytest.raises(ValueError, match='unexpected_cycle'):
|
||||
resolve_capture_plan(profile, [header, dict(kind='joint_sample', cycle=2)])
|
||||
with pytest.raises(ValueError, match='header_changed'):
|
||||
resolve_capture_plan(profile, [dict(header, capture_plan={})])
|
||||
|
||||
|
||||
def test_existing_preparation_policy_remains_available_without_stage_reordering(profile):
|
||||
from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_image_budget
|
||||
prepared = configure_preparation(profile, 'occlusion_aware_trajectory_v3')
|
||||
assert preparation_image_budget(prepared)[0] == 256
|
||||
assert CalibrationEngine(prepared).scan_units() == CalibrationEngine(profile).scan_units()
|
||||
|
||||
|
||||
def test_unresolved_task_cannot_be_moved_into_another_tasks_training_stage(profile):
|
||||
from types import SimpleNamespace
|
||||
from linkerhand_calibration.runtime.coordinator import CalibrationCoordinator
|
||||
host = SimpleNamespace(profile=profile)
|
||||
assert CalibrationCoordinator._defer_unobservable_task(
|
||||
host, None, 'image_motion_families_not_distinguishable') is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_two_round_formal_json_rebuilds_identical_urdf(tmp_path, o30_capture):
|
||||
from o30_formal_fixture import formal_capture
|
||||
from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session
|
||||
from linkerhand_calibration.runtime.artifacts.urdf_from_json import rebuild_urdf_from_json
|
||||
from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper
|
||||
|
||||
profile, source, records, hashes, cameras, truth = formal_capture(
|
||||
o30_capture, tmp_path, training_policy=TASKWISE_TWO_ROUND)
|
||||
result, fit, artifact = finalize_profile_session(profile=profile, source_urdf=source,
|
||||
records=records, protected_inputs=hashes, serial_number='SYNTHETIC_FORMAL_O30',
|
||||
session_dir=tmp_path/'session', standard_loader=ET.parse,
|
||||
require_motion_evidence=True, camera_extrinsics_file=cameras)
|
||||
assert result['capture_plan']['policy'] == TASKWISE_TWO_ROUND
|
||||
assert all(t['training_cycles'] == [0, 1] and t['holdout_cycle'] == 3
|
||||
for t in result['capture_plan']['tasks'].values())
|
||||
assert len(fit.command_mappings) == 20
|
||||
for name, value in truth.items():
|
||||
assert fit.zero_offsets_rad[name] == pytest.approx(value, abs=.004)
|
||||
rebuilt = rebuild_urdf_from_json(artifact.staged_release.calibration_json, source,
|
||||
tmp_path/'rebuilt.urdf', authorized_fields=profile.urdf_authorized_fields,
|
||||
expected_profile_id=profile.key.profile_id)
|
||||
assert rebuilt.read_bytes() == artifact.path.read_bytes()
|
||||
mapper = load_unified_mapper(artifact.staged_release.calibration_json)
|
||||
assert len(mapper.map_positions(profile.command.baseline_values)) == 20
|
||||
validation = json.loads(artifact.staged_release.validation_json)
|
||||
assert validation['final_file_image_holdout_verified'] and validation['urdf_rebuilt_from_json']
|
||||
Reference in New Issue
Block a user