Spaces:
Running
Running
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,
}
|