File size: 10,739 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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
"""清洗契约 —— 模态、信道、严重度的公共定义。

这个文件把"平台里同一个东西有好几个名字"这件事收敛到一处:

    device_naming.DEVICE_LABELS        mono_rgb / stereo_rgb / ...
    workflow_dispatch.semantic_type    同上(同一套值,只是没人敢这么说)
    api/projects._device_input_sources mono_camera / stereo_camera  ← 名字不一样
    处理模块 slug                       mono_camera / stereo_camera

**本模块不新建第 7 套枚举** —— DEVICE_MODALITIES 就是 device_naming 那 6 个值的
权威声明,其余几套通过 ``normalize_device_modality()`` 单向映射进来。新增设备类型时
只需改这一处,测试会锁住映射完整性。

信道(channel) 是比设备模态更细的粒度: PDF 里"IMU 可选""SLAM 必须"这类判断
是**信道级**的,硬塞进设备模态会毁掉它。所以设备模态 → 信道的推导放在这里。
"""

from __future__ import annotations

from dataclasses import dataclass, field as dc_field
from typing import Any, Iterable

# ── 设备模态 ──────────────────────────────────────────────────
# 与 app/device_naming.py:15 DEVICE_LABELS 的键**逐字一致**。
# 改这里必须同步改那里,tests/test_cleaning_contract.py 会断言两边相等。
DEVICE_MODALITIES: tuple[str, ...] = (
    "mono_rgb",
    "stereo_rgb",
    "rgbd_camera",
    "stereo_rgbd_camera",
    "glove_sensor",
    "gripper_device",
)

# 「摄像机类」模态 —— 判定 collection_type 时要用
CAMERA_MODALITIES: frozenset[str] = frozenset({
    "mono_rgb", "stereo_rgb", "rgbd_camera", "stereo_rgbd_camera",
})

# 会产出 RGB 视频的**全部**设备卡片 —— ``video.*`` 类检查适用于它们每一个。
# 比 CAMERA_MODALITIES 多一个 gripper_device:UMI 夹爪自带鱼眼相机,
# 视频检查对同样它成立。
VIDEO_CAPABLE_MODALITIES: tuple[str, ...] = (
    "mono_rgb", "stereo_rgb", "rgbd_camera", "stereo_rgbd_camera",
    "gripper_device",
)

# ── 信道 ──────────────────────────────────────────────────────
# 检查项按信道声明需求,而不是按设备模态 —— 这样一条"SLAM 连续性"检查
# 既能被 UMI 夹爪触发,将来也能被别的带 SLAM 的设备复用。
CHANNELS: tuple[str, ...] = (
    "time",           # 时间戳 / 帧号
    "rgb",            # RGB 视频
    "depth",          # 深度
    "slam",           # SLAM 位姿/轨迹
    "gripper_state",  # 夹爪开合
    "force",          # 力 / 触觉(夹爪力)
    "tactile",        # 触觉阵列(手套)
    "imu",            # 惯性单元
    "keypoints",      # 手部关键点
    "action",         # 动作(派生列)
    # 设备的连接状态(``status.left_glove`` 这类**字符串**列)。
    # 单独算一个信道而不是塞进 tactile 的 series:它是"设备这一帧在不在",
    # 与阵列读数正交 —— 手套掉线时阵列会静默保持最后一个值,看起来完全正常。
    "device_status",
)

# 设备模态 → 该设备**可能**提供的信道。
# 注意这是"可能"不是"必然" —— 实际有哪些信道由 collect.py 从真实列名推导,
# 这里只用来回答"这个模态需不需要某类检查"。
MODALITY_CHANNELS: dict[str, tuple[str, ...]] = {
    "mono_rgb":           ("rgb", "time"),
    "stereo_rgb":         ("rgb", "time"),
    "rgbd_camera":        ("rgb", "depth", "time"),
    "stereo_rgbd_camera": ("rgb", "depth", "time"),
    "glove_sensor":       ("tactile", "device_status", "imu", "time"),
    "gripper_device":     ("rgb", "slam", "gripper_state", "force", "action", "time"),
}

# 非设备来源的信道 —— 由处理节点产出,不属于任何输入设备
DERIVED_CHANNELS: frozenset[str] = frozenset({"keypoints", "action"})


def channels_for(modalities: Iterable[str]) -> tuple[str, ...]:
    """一组设备模态覆盖的全部信道(去重、保序)。纯函数。"""
    seen: dict[str, None] = {}
    for modality in modalities:
        for channel in MODALITY_CHANNELS.get(str(modality), ()):
            seen.setdefault(channel, None)
    return tuple(seen)


def pose_is_valid(item) -> bool:
    """位姿能不能用 —— 空元组和**全零占位**都不算。

    ★ 全零占位长得像合法位姿:长度 7、能索引、能相减。但它的四元数模是 0,
      任何涉及它的差分都会算出"瞬移":

          两个零四元数的点积 = 0 → acos(0) = π/2 → 夹角 180°

      30fps 下就是 180°×30 = **94.25 rad/s** —— 一个物理上荒谬的角速度。
      实测 Test94 的 ``observation.slam_pose`` 有 437/599 行是全零,SLAM 连续性
      因此报出 465 处"跳变"(599 帧里几乎每一帧),把整集假判成 FAIL 并拦住批次。

    判据只看四元数模:**位置可以合法地是 (0,0,0)**(原点就是合法位姿),
    四元数模为 0 则一定不是合法朝向。

    放在 contract 而不是 collect:检查项要用它,而 collect 依赖 checks
    (检查项自动发现),collect ← checks 的导入会成环。
    """
    if not item or len(item) < 7:
        return False
    try:
        return sum(float(value) ** 2 for value in item[3:7]) > 1e-12
    except (TypeError, ValueError):
        return False


def normalize_device_modality(value: object) -> str | None:
    """把别处的设备/输入类型名映射到 canonical 设备模态。

    覆盖两套现存命名:
      * ``stereo_camera`` / ``mono_camera``(api/projects、processing 模块 slug)
      * ``fisheye_camera`` / ``rgb_camera``(历史工作流节点类型)
    认不出来返回 ``None``(调用方应跳过该值,而不是终止)。
    """
    text = str(value or "").strip()
    if not text:
        return None
    if text in DEVICE_MODALITIES:
        return text
    return _ALIASES.get(text)


# 别名表 —— 只列真实存在于代码里的历史/并行命名。
# 新增别名时同步加进 tests/test_cleaning_contract.py 的用例。
_ALIASES: dict[str, str] = {
    # api/projects._device_input_sources 与处理模块 slug 用的是 camera 后缀
    "mono_camera": "mono_rgb",
    "stereo_camera": "stereo_rgb",
    "fisheye_camera": "mono_rgb",     # 历史节点,镜头类型对清洗不可见
    "rgb_camera": "mono_rgb",         # 历史节点
    "rgbd": "rgbd_camera",
    "stereo_rgbd": "stereo_rgbd_camera",
    # 中文/展示名兜底
    "umi": "gripper_device",
    "umi_gripper": "gripper_device",
    "glove": "glove_sensor",
}


# ── 严重度 ────────────────────────────────────────────────────
PASS = "pass"
WARN = "warn"
FAIL = "fail"
PENDING = "pending_review"
ERROR = "error"

# 越靠前越严重 —— worst() 与排序都用它
SEVERITY_ORDER: tuple[str, ...] = (ERROR, FAIL, PENDING, WARN, PASS)

# 面向用户的展示文案(报告里的 ``status_label``)。界面是英文的,这里保持一致。
SEVERITY_LABELS: dict[str, str] = {
    PASS: "Pass",
    WARN: "Warning",
    FAIL: "Fail",
    PENDING: "Pending review",
    ERROR: "Error",
}


def worst(statuses: Iterable[str]) -> str:
    """取一组状态里最严重的;空集合视为 PASS。纯函数。"""
    rank = {status: index for index, status in enumerate(SEVERITY_ORDER)}
    result = PASS
    best = len(SEVERITY_ORDER)
    for status in statuses:
        index = rank.get(str(status), len(SEVERITY_ORDER))
        if index < best:
            best = index
            result = str(status)
    return result


def is_blocking(status: str) -> bool:
    """该状态是否应当拦截(退回人工审核)。"""
    return str(status) in (FAIL, ERROR)


# ── 结构 ──────────────────────────────────────────────────────
@dataclass(frozen=True)
class Range:
    """一段时间区间 —— PDF §5「第二层结果」。"""

    start_sec: float
    end_sec: float
    code: str
    severity: str
    stream: str = ""
    channel: str = ""
    start_frame: int | None = None
    end_frame: int | None = None
    rule: str = ""
    message: str = ""
    evidence: dict[str, Any] = dc_field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "start_sec": round(float(self.start_sec), 3),
            "end_sec": round(float(self.end_sec), 3),
            "code": self.code,
            "severity": self.severity,
            "stream": self.stream,
            "channel": self.channel,
            "start_frame": self.start_frame,
            "end_frame": self.end_frame,
            "rule": self.rule,
            "message": self.message,
            "evidence": dict(self.evidence or {}),
        }


@dataclass(frozen=True)
class StreamResult:
    """一条数据流的检查结果 —— PDF §5「第一层结果」。"""

    stream: str
    modality: str = ""
    label: str = ""
    channels: tuple[str, ...] = ()
    status: str = PASS
    reasons: tuple[str, ...] = ()
    metrics: dict[str, Any] = dc_field(default_factory=dict)
    ranges: tuple[Range, ...] = ()

    def to_dict(self) -> dict[str, Any]:
        return {
            "stream": self.stream,
            "modality": self.modality,
            "label": self.label,
            "channels": list(self.channels),
            "status": self.status,
            "reasons": list(self.reasons),
            "metrics": dict(self.metrics or {}),
            "ranges": [item.to_dict() for item in self.ranges],
        }


@dataclass(frozen=True)
class Finding:
    """一次检查产出的结论 —— 检查项 run() 的返回值单元。"""

    rule: str
    version: str
    status: str
    scope: str                        # stream | episode | cross_modal
    stream: str = ""
    message: str = ""
    metrics: dict[str, Any] = dc_field(default_factory=dict)
    ranges: tuple[Range, ...] = ()
    elapsed_ms: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "rule": self.rule,
            "version": self.version,
            "status": self.status,
            "scope": self.scope,
            "stream": self.stream,
            "message": self.message,
            "metrics": dict(self.metrics or {}),
            "ranges": [item.to_dict() for item in self.ranges],
            "elapsed_ms": self.elapsed_ms,
        }