File size: 5,918 Bytes
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
"""夹爪量程与力基线 —— 开合度越界、力读数漂移。

为什么重要
----------
**开合度越界**:``umi_slam_action._gripper_fraction()`` 会把开合度
``np.clip(0, 1)``。也就是说越界值在派生 action 时被**静默夹掉**了 ——
标定异常的集看起来"正常",但动作里的夹爪维度是失真的。本项就是在
clip 之前把它抓出来(原始列 ``observation.gripper_state[0]`` 是 0–100)。

**力基线漂移**:夹爪没接触物体时力读数应在零附近。若整集的静息基线缓慢
上移,说明传感器漂移/温漂,用力做阈值判断的模型会失真。

判定口径
--------
开合度越界默认判 **WARN**(数据还能用,但要知道标定有问题);
力漂移默认判 **WARN**(同上)。两者都不拦数据,只报告。
"""

from __future__ import annotations

import math

from app.processing.cleaning.checks import (
    CheckContext, CleaningCheck, register_check,
    Finding, Range, PASS, WARN,
)

# 原始列里开合度的量程(采集端契约:[open_pct, gripped, fz_mn])
OPEN_PCT_MIN = 0.0
OPEN_PCT_MAX = 100.0


def _median(values: list[float]) -> float:
    if not values:
        return 0.0
    ordered = sorted(values)
    middle = len(ordered) // 2
    if len(ordered) % 2:
        return ordered[middle]
    return (ordered[middle - 1] + ordered[middle]) / 2.0


@register_check
class UmiGripperRangeCheck(CleaningCheck):
    slug = "umi.gripper_range"
    label = "Gripper Range / Force Baseline"
    version = "1.0"
    description = "Gripper opening out of range, or force baseline drift"

    requires_channels = ("gripper_state",)

    # 属于哪些设备卡片 —— 前端设置面板按它分 tab

    device_cards = ("gripper_device",)

    default_params = {
        "max_out_of_range_ratio": 0.01,   # 越界帧占比上限
        "max_force_drift": 200.0,          # 前后半段基线差(mN)
    }
    default_severity = WARN

    def run(self, ctx: CheckContext) -> list[Finding]:
        findings: list[Finding] = []
        max_ratio = float(self.param(ctx, "max_out_of_range_ratio", 0.01))
        max_drift = float(self.param(ctx, "max_force_drift", 200.0))

        for stream in ctx.streams_of("gripper_state"):
            samples = stream.series.get("gripper_state") or ()
            if not samples:
                continue
            fps = float(stream.fps or ctx.fps or 30.0)

            out_of_range: list[Range] = []
            values: list[float] = []
            for index, sample in enumerate(samples):
                if not sample:
                    continue
                try:
                    pct = float(sample[0])
                except (TypeError, ValueError, IndexError):
                    continue
                values.append(pct)
                if OPEN_PCT_MIN <= pct <= OPEN_PCT_MAX:
                    continue
                out_of_range.append(Range(
                    start_sec=index / fps, end_sec=(index + 1) / fps,
                    code=self.slug, severity=WARN,
                    stream=stream.key, channel="gripper_state",
                    start_frame=index, end_frame=index,
                    rule=self.slug,
                    message=f"opening {pct:.1f} is outside the 0-100 range"
                            f"(派生 action 时会被静默夹掉)",
                    evidence={"open_pct": round(pct, 2)},
                ))

            if not values:
                continue

            ratio = len(out_of_range) / len(values)
            metrics: dict = {
                "samples": len(values),
                "out_of_range": len(out_of_range),
                "out_of_range_ratio": round(ratio, 4),
                "min": round(min(values), 2),
                "max": round(max(values), 2),
            }

            if ratio > max_ratio:
                findings.append(self.finding(
                    ctx, WARN, stream=stream.key,
                    message=f"{len(out_of_range)}/{len(values)} frames out of range"
                            f"({ratio:.1%}),标定可能异常",
                    metrics=metrics,
                    ranges=out_of_range[:50],
                ))
            else:
                findings.append(self.finding(ctx, PASS, stream=stream.key,
                                             metrics=metrics))

        # 力基线漂移 —— 独立的信道,单独一轮
        for stream in ctx.streams_of("force"):
            forces = stream.series.get("force") or ()
            if len(forces) < 20:
                continue
            magnitudes = []
            for sample in forces:
                if not sample:
                    continue
                try:
                    magnitudes.append(_norm3(sample))
                except (TypeError, ValueError):
                    continue
            if len(magnitudes) < 20:
                continue

            half = len(magnitudes) // 2
            first, second = _median(magnitudes[:half]), _median(magnitudes[half:])
            drift = abs(second - first)
            metrics = {"baseline_first_half": round(first, 2),
                       "baseline_second_half": round(second, 2),
                       "drift_mN": round(drift, 2)}

            if drift <= max_drift:
                findings.append(self.finding(ctx, PASS, stream=stream.key,
                                             metrics=metrics))
                continue
            findings.append(self.finding(
                ctx, WARN, stream=stream.key,
                message=f"force baseline drift {drift:.0f} mN"
                        f"(前半 {first:.0f} → 后半 {second:.0f}),疑似传感器漂移",
                metrics=metrics,
            ))
        return findings


def _norm3(sample) -> float:
    return math.sqrt(sum(float(value) ** 2 for value in sample[:3]))