255漂移问题稳定时间改30秒

This commit is contained in:
lxp
2026-09-18 13:37:03 +08:00
parent 32b6af62e2
commit a8eaa4c367
8 changed files with 1798 additions and 15 deletions
+3 -2
View File
@@ -205,8 +205,9 @@ SDK 自身的初始化检查和设备保护保持原样。标定仍要求 SDK
先按实际时间戳估计四角点的移动趋势,再用去除趋势后的 MAD 和相邻帧差分估计随机抖动。 先按实际时间戳估计四角点的移动趋势,再用去除趋势后的 MAD 和相邻帧差分估计随机抖动。
全窗口及前、后半窗口分别检查趋势,避免缓慢往返运动在整个窗口中互相抵消;每个半窗口至少4帧。 全窗口及前、后半窗口分别检查趋势,避免缓慢往返运动在整个窗口中互相抵消;每个半窗口至少4帧。
持续漂移取各段 `max(0, 趋势累计位移 - 抖动裕量)` 的最大值,裕量由随机抖动、帧数和时间分布计算。 持续漂移取各段 `max(0, 趋势累计位移 - 抖动裕量)` 的最大值,裕量由随机抖动、帧数和时间分布计算。
两项门槛独立生效:随机抖动允许到0.5像素,低噪声条件下的持续漂移仍按0.1像素检查。任一项超限就保持当前指令继续等待,超过 `point_timeout` 仍不能稳定则暂停。 两项门槛独立生效:随机抖动允许到0.5像素,低噪声条件下的持续漂移仍按0.1像素检查。任一项超限就保持当前指令继续等待,到达该阶段的等待时限仍不能稳定则暂停。
`point_timeout` 默认6秒,给换向后的短时振动留出衰减时间;这是最长等待时间,达到稳定条件就立即采样推进。持续振动仍会暂停,不通过放宽噪声或漂移门槛获得结果。 端点参考使用独立的 `reference_timeout`,默认30秒,给大幅定位后的缓慢位移留出稳定时间;普通逐点采样、准备和回位仍使用 `point_timeout`,默认6秒。等待从软件轨迹到达目标时开始,达到原有稳定条件就立即推进,不固定等满时限。端点参考超时会明确提示“端点参考未稳定”、保持目标的时长以及具体 Tag 的原因,日志同时保存 `elapsed_seconds` 和 `timeout_seconds`。扫描过程中丢失 Tag 导致轨迹暂停仍使用6秒时限。
延长参考等待不会放宽噪声或漂移门槛;255的参考观测通过稳定检查后,才开始发送254。若30秒后仍不满足稳定条件,程序继续按超时暂停处理;需结合实机排查手指、Tag 粘贴和相机/手掌固定处。
这是对有限图像的估计,并非已完全静止的保证。高噪声会降低细小运动的可辨别性,实机准确性仍需复测;不根据四指一起变化自动扣除整体位移,以免消除四指真实的同步运动。 这是对有限图像的估计,并非已完全静止的保证。高噪声会降低细小运动的可辨别性,实机准确性仍需复测;不根据四指一起变化自动扣除整体位移,以免消除四指真实的同步运动。
旧工位文件的 `stability_px` 仍可加载,仅作为随机抖动上限;新增配置优先,漂移上限默认0.1。建议自定义工位改用上述三个明确字段。 旧工位文件的 `stability_px` 仍可加载,仅作为随机抖动上限;新增配置优先,漂移上限默认0.1。建议自定义工位改用上述三个明确字段。
@@ -21,7 +21,8 @@ scan:
stable_seconds: 0.5 # 新图像必须覆盖连续静止时间,不能只凭几帧局部平稳。 stable_seconds: 0.5 # 新图像必须覆盖连续静止时间,不能只凭几帧局部平稳。
reference_seconds: 1.5 # 每端开始前额外核对前、中、后三段参考位置一致。 reference_seconds: 1.5 # 每端开始前额外核对前、中、后三段参考位置一致。
motion_frames: 2 # 瞬时运动须超过参考和当前点的抖动包络;不看方向。 motion_frames: 2 # 瞬时运动须超过参考和当前点的抖动包络;不看方向。
point_timeout: 6 # 最长等待振动衰减时间;稳定后立即推进,不固定等满6秒。 point_timeout: 6 # 普通逐点采样的最长等待;稳定后立即推进。
reference_timeout: 30 # 到达端点后允许更长时间稳定;达标立即开始,不固定等满30秒。
freshness: 0.5 freshness: 0.5
feedback_tolerance: 2 feedback_tolerance: 2
repeat_tolerance: 2 repeat_tolerance: 2
@@ -232,8 +232,20 @@ class Engine:
'reason': reason}) 'reason': reason})
return issues return issues
@property
def collecting_reference(self):
return self.state == 'SCANNING' and any(
isinstance(window, ReferenceWindow) for window in self.windows.values())
@property
def waiting_timeout(self):
return (self.settings.reference_timeout if self.collecting_reference
else self.settings.point_timeout)
def _pause_sampling_timeout(self, now, moving=False): def _pause_sampling_timeout(self, now, moving=False):
restoring = self.state == 'RESTORING' restoring = self.state == 'RESTORING'
limit = self.settings.point_timeout if moving else self.waiting_timeout
elapsed = now - (self.blocked_since if moving else self.arrived)
issues = [] if restoring else self._visibility_issues(now) issues = [] if restoring else self._visibility_issues(now)
if not moving: if not moving:
indices = self.active_indices if self.state == 'SCANNING' else None indices = self.active_indices if self.state == 'SCANNING' else None
@@ -248,8 +260,14 @@ class Engine:
view, tag_id = self.keys[name] view, tag_id = self.keys[name]
issues.append({'source': 'tag', 'joint': name, 'view': view, issues.append({'source': 'tag', 'joint': name, 'view': view,
'tag_id': tag_id, 'window_samples': window.snapshot(), **problem}) 'tag_id': tag_id, 'window_samples': window.snapshot(), **problem})
prefix = ('目标 Tag 持续不可见或观测过期' if moving else if moving:
'测后回位稳定超时' if restoring else '单点稳定采样超时') prefix = '目标 Tag 持续不可见或观测过期'
elif restoring:
prefix = '测后回位稳定超时'
elif self.collecting_reference:
prefix = f'端点参考未稳定:等待超时(已保持目标 {elapsed:.1f} 秒,上限 {limit:g} 秒)'
else:
prefix = '单点稳定采样超时'
details = '\n'.join(describe_issue(issue) for issue in issues) details = '\n'.join(describe_issue(issue) for issue in issues)
shared_motion = shared_translation(issues) shared_motion = shared_translation(issues)
if shared_motion: if shared_motion:
@@ -258,9 +276,10 @@ class Engine:
# Conditions can recover on the tick that crosses the deadline. # Conditions can recover on the tick that crosses the deadline.
context = self.restoring_joint if restoring else '、'.join( context = self.restoring_joint if restoring else '、'.join(
tag_label(view, tag_id, name) for name, (view, tag_id) in self.keys.items()) tag_label(view, tag_id, name) for name, (view, tag_id) in self.keys.items())
details = f'{context}:条件刚恢复,但已超过 {self.settings.point_timeout:g} 秒等待时限' details = f'{context}:条件刚恢复,但已超过 {limit:g} 秒等待时限'
self._record('sampling_timeout', time=now, task=self.task.name, stage=self.stage, self._record('sampling_timeout', time=now, task=self.task.name, stage=self.stage,
direction=self.direction, repeat=self.repeat, target=list(self.command), direction=self.direction, repeat=self.repeat, target=list(self.command),
elapsed_seconds=elapsed, timeout_seconds=limit,
issues=issues, shared_motion=shared_motion, reason=details) issues=issues, shared_motion=shared_motion, reason=details)
self.pause(prefix + '\n' + details) self.pause(prefix + '\n' + details)
@@ -308,7 +327,7 @@ class Engine:
for window in self.windows.values(): for window in self.windows.values():
window.clear() window.clear()
return return
if now-self.arrived > self.settings.point_timeout: if now-self.arrived > self.waiting_timeout:
self._pause_sampling_timeout(now) self._pause_sampling_timeout(now)
return return
if self.state == 'RESTORING': if self.state == 'RESTORING':
@@ -97,6 +97,7 @@ class Settings:
reference_seconds: float = 1.5 # Verify the endpoint across independent time blocks. reference_seconds: float = 1.5 # Verify the endpoint across independent time blocks.
motion_frames: int = 2 # Consecutive valid frames at this command, independent of direction. motion_frames: int = 2 # Consecutive valid frames at this command, independent of direction.
point_timeout: float = 6 point_timeout: float = 6
reference_timeout: float = 30 # Endpoint positioning can settle slower than a one-unit step.
freshness: float = 0.5 freshness: float = 0.5
feedback_tolerance: float = 2 feedback_tolerance: float = 2
non_target_tolerance: float = 3 # Legacy configuration only; unrelated motion does not gate scans. non_target_tolerance: float = 3 # Legacy configuration only; unrelated motion does not gate scans.
@@ -141,8 +142,8 @@ class Settings:
raise ValueError('单点超时必须大于最短等待与稳定观察时长之和') raise ValueError('单点超时必须大于最短等待与稳定观察时长之和')
if result.reference_seconds < result.stable_seconds: if result.reference_seconds < result.stable_seconds:
raise ValueError('端点参考观察时长不能短于普通稳定观察时长') raise ValueError('端点参考观察时长不能短于普通稳定观察时长')
if result.point_timeout <= result.settle_seconds + result.reference_seconds: if result.reference_timeout <= result.settle_seconds + result.reference_seconds:
raise ValueError('单点超时必须大于最短等待与端点参考观察时长之和') raise ValueError('端点参考超时必须大于最短等待与端点参考观察时长之和')
return result return result
File diff suppressed because it is too large Load Diff
@@ -618,7 +618,8 @@ def test_pinky_pip_uses_its_own_tag_and_feedback_despite_coupled_motion(scenario
for event in commands) for event in commands)
if scenario in ('missing_side', 'moving_active_feedback'): if scenario in ('missing_side', 'moving_active_feedback'):
assert engine.state == 'PAUSED' assert engine.state == 'PAUSED'
assert ('Tag' if scenario == 'missing_side' else '稳定采样超时') in engine.reason assert ('侧面 Tag ID5' if scenario == 'missing_side'
else '关节 pinky_pip:反馈未稳定') in engine.reason
assert not engine.samples assert not engine.samples
else: else:
assert engine.state == 'COMPLETED', engine.reason assert engine.state == 'COMPLETED', engine.reason
@@ -208,6 +208,8 @@ def test_one_pass_four_fingers_with_late_settling_noise_and_replay(tmp_path, see
def test_reference_duration_and_timeout_configuration_are_validated(): def test_reference_duration_and_timeout_configuration_are_validated():
for change in ({'reference_seconds': .1}, {'point_timeout': 1}): for change in ({'reference_seconds': .1}, {'reference_timeout': 1}):
with pytest.raises(ValueError, match='参考观察时长'): with pytest.raises(ValueError, match='参考观察时长'):
Settings.from_dict(change) Settings.from_dict(change)
# Each deadline validates only the observation window it actually governs.
assert Settings.from_dict({'point_timeout': 1}).reference_timeout == 30
@@ -1,5 +1,7 @@
"""Timeout explanations must identify actual blockers before windows are cleared.""" """Timeout explanations must identify actual blockers before windows are cleared."""
from dataclasses import replace from dataclasses import replace
import json
from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
@@ -14,10 +16,10 @@ from linkerhand_range_calibration.vision.reference import ReferenceWindow
class SamplingRun: class SamplingRun:
def __init__(self, task='thumb_cmc_roll', phase='waiting'): def __init__(self, task='thumb_cmc_roll', phase='waiting', settings=None):
profile = load_profile() profile = load_profile()
settings = Settings(rate=1000, stable_frames=4, stable_seconds=.15, reference_seconds=.45, settings = settings or Settings(rate=1000, stable_frames=4, stable_seconds=.15, reference_seconds=.45,
settle_seconds=.05, point_timeout=.8) settle_seconds=.05, point_timeout=.8, reference_timeout=.8)
self.adapter = FakeAdapter(profile) self.adapter = FakeAdapter(profile)
self.events, self.now = [], 1.0 self.events, self.now = [], 1.0
self.engine = Engine(profile, settings, self.adapter, self.events.append) self.engine = Engine(profile, settings, self.adapter, self.events.append)
@@ -106,7 +108,7 @@ def test_four_finger_sampling_advances_with_random_jitter_above_old_limit():
@pytest.mark.parametrize('deadline,settles_at', [(3, 4), (6, 4), (6, 0), (6, None)]) @pytest.mark.parametrize('deadline,settles_at', [(3, 4), (6, 4), (6, 0), (6, None)])
def test_waits_for_transient_vibration_without_advancing_or_relaxing_limits(deadline, settles_at): def test_waits_for_transient_vibration_without_advancing_or_relaxing_limits(deadline, settles_at):
run = SamplingRun('four_finger_roll') run = SamplingRun('four_finger_roll')
run.engine.settings = replace(run.engine.settings, point_timeout=deadline) run.engine.settings = replace(run.engine.settings, reference_timeout=deadline)
started, sent = run.now, len(run.adapter.sent) started, sent = run.now, len(run.adapter.sent)
def shake(frames, rejected): def shake(frames, rejected):
@@ -136,6 +138,95 @@ def test_waits_for_transient_vibration_without_advancing_or_relaxing_limits(dead
assert observation['jitter_px'] <= .5 and observation['drift_px'] <= .1 assert observation['jitter_px'] <= .5 and observation['drift_px'] <= .1
def test_recorded_upper_reference_drift_is_still_rejected():
path = Path(__file__).parent / 'fixtures' / 'group_upper_reference_drift.json'
recorded = json.loads(path.read_text())
settings = Settings()
assert recorded['target'] == [255] * 4
assert settings.point_timeout < recorded['held_seconds'] < settings.reference_timeout
assert {issue['tag_id'] for issue in recorded['issues']} == {2, 3}
for issue in recorded['issues']:
window = ReferenceWindow(settings)
for sample in issue['window_samples']:
window.add(Observation(sample['stamp_ns'], 0, sample['corners']))
problem = window.problem(settings.stability_noise_px, settings.stability_drift_px)
assert problem['drift_px'] == pytest.approx(issue['drift_px'])
assert problem['jitter_px'] < .02
assert '持续漂移' in problem['reason']
# The real recording ends at the timeout. Recovery below is synthetic;
# it tests scheduling without claiming the real hardware settled later.
@pytest.mark.parametrize('direction', ['up', 'down'])
@pytest.mark.parametrize('settles_at', [0, 8, 18, None])
def test_endpoint_holds_until_stable_or_reference_deadline(direction, settles_at):
settings = Settings(rate=10000, stable_frames=4, settle_seconds=.05)
run = SamplingRun('four_finger_roll', settings=settings)
for _ in range(2000):
if (run.engine.direction == direction and run.engine.collecting_reference
and run.engine.phase == 'waiting'):
break
run.step()
else:
pytest.fail('未到达端点参考采样阶段')
started = run.engine.arrived
sent, events_before = len(run.adapter.sent), len(run.events)
target = run.engine.command
def slow_settling(frames, rejected):
elapsed = run.now - started
# Middle/ring drift independently of stable encoder feedback.
travel = elapsed if settles_at is None else min(elapsed, settles_at)
for tag, rate in [(3, .3), (2, .6)]:
frames['front'][tag] = (np.asarray(frames['front'][tag]) + [travel * rate, 0]).tolist()
for _ in range(round((settings.reference_timeout + 1) / .05)):
run.step(slow_settling)
samples = [event for event in run.events[events_before:] if event['kind'] == 'sample']
assert len(run.adapter.sent) == sent and run.engine.command == target
if samples or run.engine.state == 'PAUSED':
break
if settles_at is None:
assert not samples and run.engine.state == 'PAUSED'
assert '端点参考未稳定' in run.engine.reason
timeout = run.events[-2]
assert timeout['kind'] == 'sampling_timeout'
assert timeout['timeout_seconds'] == settings.reference_timeout
assert settings.reference_timeout < timeout['elapsed_seconds'] <= settings.reference_timeout + .1
assert {issue['tag_id'] for issue in timeout['issues']} == {2, 3}
else:
assert len(samples) == 1 and run.engine.state == 'SCANNING'
assert settles_at <= run.now - started < settles_at + 2
for observation in samples[0]['observations'].values():
assert observation['drift_px'] <= .1 and observation['jitter_px'] <= .5
if settles_at:
assert run.now - started > settings.point_timeout
assert all(search.boundary is None for search in run.engine.searches.values())
def test_ordinary_command_does_not_inherit_reference_deadline():
run = SamplingRun('four_finger_roll', settings=Settings(rate=10000, stable_frames=4, settle_seconds=.05))
for _ in range(300):
run.step()
if run.engine.stage == 'search' and run.engine.phase == 'waiting':
break
assert run.engine.stage == 'search' and not run.engine.collecting_reference
started, sent = run.engine.arrived, len(run.adapter.sent)
def creep(frames, rejected):
frames['front'][2] = (np.asarray(frames['front'][2]) + [run.now - started, 0]).tolist()
for _ in range(130):
run.step(creep)
if run.engine.state == 'PAUSED':
break
assert run.engine.state == 'PAUSED' and '单点稳定采样超时' in run.engine.reason
assert len(run.adapter.sent) == sent
timeout = run.events[-2]
assert timeout['timeout_seconds'] == 6
assert 6 < timeout['elapsed_seconds'] <= 6.1
@pytest.mark.parametrize('phase', ['moving', 'waiting']) @pytest.mark.parametrize('phase', ['moving', 'waiting'])
@pytest.mark.parametrize('reason', [None, 'Tag 像素尺寸不足']) @pytest.mark.parametrize('reason', [None, 'Tag 像素尺寸不足'])
def test_missing_or_rejected_tag_does_not_blame_other_group_tags(phase, reason): def test_missing_or_rejected_tag_does_not_blame_other_group_tags(phase, reason):