File size: 13,072 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
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""清洗检查项注册表 —— 一个文件 = 一个检查项,自动发现。

与 app/processing/modules/ 同一套思路(见那里的 __init__.py):

    for _m in pkgutil.iter_modules(__path__):
        importlib.import_module(...)

新增检查项只需在 checks/ 下放一个 .py,内部用 @register_check 装饰类定义。

检查项**按信道/模态声明需求**,不按"这个 episode 是 UMI 还是 EGO"。
所以同一份检查项集合能自动适配任何数据组合:
    UMI 夹爪来了 → 只跑需要 slam/force/gripper_state 的那些
    触觉手套来了 → 只跑需要 tactile 的那些
    EGO 来了     → 只跑需要 rgb/keypoints 的那些
这就是 PDF §2「用采集模板决定跑哪些清洗规则」的落地方式。
"""

from __future__ import annotations

import importlib
import pkgutil
from dataclasses import dataclass, field as dc_field
from pathlib import Path
from typing import Any, Callable, Iterable

from app.processing.cleaning.contract import (
    Finding, Range, PASS, WARN, FAIL, PENDING, ERROR,
    SEVERITY_LABELS, worst, is_blocking,
)

__all__ = [
    "CheckContext", "StreamEvidence", "CleaningCheck", "register_check",
    "get_check", "all_checks", "all_check_specs", "applicable_checks",
    "checks_for_device_cards",
    "Finding", "Range", "PASS", "WARN", "FAIL", "PENDING", "ERROR",
    "SEVERITY_LABELS", "worst", "is_blocking",
]

_registry: dict[str, "CleaningCheck"] = {}


@dataclass(frozen=True)
class StreamEvidence:
    """一条数据流的证据 —— 由 evidence.py 采集,检查项**只读不采**。

    把"读磁盘"和"做判断"分开,是让检查项可纯测的关键:测试直接构造本对象,
    不需要真的 mp4 / parquet / cv2。
    """

    key: str                             # 流标识,如 "umi_rgb" / "slam_trajectory"
    channel: str = ""                    # rgb | depth | slam | tactile | ...
    path: str = ""                       # 相对批次目录的路径(仅用于报错定位)
    # video_quality 的报告(黑屏/冻结/丢帧/解码)。非视频流为空 dict。
    # 结构见 app/video_quality.py::_check_stream 的返回。
    video: dict[str, Any] = dc_field(default_factory=dict)
    # parquet 概览
    columns: tuple[str, ...] = ()
    rows: int = 0
    fps: float = 0.0
    # 逐帧数值序列(按帧对齐)。evidence.py 采集时**派生好的**,检查项不自己算。
    # 例:SLAM 流 → {"pose": ((x,y,z,qx,qy,qz,qw), ...), "t": (...)}
    #     夹爪流 → {"gripper_state": ((open, gripped, raw), ...),
    #               "force": ((fx,fy,fz), ...)}
    # ★ 派生复用 app/umi_slam_action.py 的函数,保证与 action 口径同源。
    series: dict[str, tuple] = dc_field(default_factory=dict)


@dataclass
class CheckContext:
    """检查项执行上下文。

    IO 走注入(``read_columns`` / ``probe_video`` …),这样检查项本体是纯逻辑,
    测试可以喂假数据而不需要真的 mp4 或 cv2。
    """

    batch_dir: Path                      # 批次目录(含 data/ videos/ meta/)
    episode_id: str = ""
    channels: frozenset[str] = frozenset()      # 实际存在的信道
    modalities: tuple[str, ...] = ()            # 实际存在的设备模态
    params: dict[str, Any] = dc_field(default_factory=dict)   # 节点配置里的阈值
    duration_sec: float = 0.0
    fps: float = 0.0
    progress: Callable[[float], None] = lambda _value: None

    # ── 证据包(evidence.py 采集好,检查项只读)────────────────
    streams: tuple["StreamEvidence", ...] = ()
    # parquet 侧的汇总:行数 / 时间戳列等,跨流共用的信息
    data_rows: int = 0
    data_columns: tuple[str, ...] = ()

    # ── 注入式 IO ────────────────────────────────────────────
    read_columns: Callable[[Path], list[str]] = lambda _p: []
    read_head: Callable[[Path, int], list[dict]] = lambda _p, _n: []
    probe_video: Callable[[Path], dict] = lambda _p: {}

    # ── 证据查询便捷方法 ──────────────────────────────────────
    def streams_of(self, channel: str) -> tuple["StreamEvidence", ...]:
        """取某信道的全部流。检查项用它筛选自己要看的流。"""
        return tuple(item for item in self.streams if item.channel == channel)

    def param(self, name: str, default: Any = None) -> Any:
        """取阈值配置;节点未配置时回落到检查项声明的默认值。"""
        value = self.params.get(name)
        return default if value is None else value


class CleaningCheck:
    """清洗检查项基类。

    子类填元数据 + 实现 ``run()``,用 ``@register_check`` 装饰即可被自动发现。
    """

    slug: str = ""                       # "video.frame_drop"
    label: str = ""
    version: str = "1.0"                 # 改了要 bump —— 进 ruleset_revision
    description: str = ""

    # 必需的信道/设备模态;全部满足才跑
    requires_channels: tuple[str, ...] = ()
    requires_modality: tuple[str, ...] = ()
    # 需要 ≥2 类设备模态同时存在(PDF 的"跨模态联合检查")
    cross_modal: bool = False

    # ★ 本项属于哪些【设备卡片】—— 前端按它把检查项分到设置面板的各 tab。
    #
    # 取值来自 contract.DEVICE_MODALITIES,空元组 = 不绑定单个设备
    # (跨设备检查用,它们只在"跨设备" tab 里出现)。
    #
    # 为什么用声明而不是靠目录分:同一个检查项往往多张卡片都要用 ——
    # ``video.frame_drop`` 对 RGB Camera、RGB-D Camera、UMI Gripper 都成立,
    # 按目录分只能放一处。声明式的话它能同时出现在多个 tab 里,
    # 且各自的阈值配置互不影响(见 node.data.config.devices.<卡片>.params)。
    device_cards: tuple[str, ...] = ()

    default_severity: str = FAIL
    # 阈值默认值 —— 节点配置可覆盖。PDF 明确要求"阈值不要照搬",
    # 所以这里给的是**平台初始值**,必须在真实数据上验证后再固化。
    default_params: dict[str, Any] = dc_field(default_factory=dict)

    def param(self, ctx: "CheckContext", name: str, fallback: Any = None) -> Any:
        """取阈值。顺序:节点配置 → 本项声明的 default_params → fallback。

        检查项一律用这个方法读阈值,不要写 ``ctx.params.get(..., 字面量)`` ——
        那样声明的 default_params 就成了死代码,改它不生效。
        """
        value = (ctx.params or {}).get(name)
        if value is not None:
            return value
        if name in self.default_params:
            return self.default_params[name]
        return fallback

    def run(self, ctx: CheckContext) -> list[Finding]:
        raise NotImplementedError

    # ── 便捷构造 ─────────────────────────────────────────────
    def finding(self, ctx: CheckContext, status: str, *,
                stream: str = "", message: str = "",
                metrics: dict | None = None,
                ranges: Iterable[Range] = ()) -> Finding:
        return Finding(
            rule=self.slug, version=self.version, status=status,
            scope="cross_modal" if self.cross_modal else "stream",
            stream=stream, message=message, metrics=dict(metrics or {}),
            ranges=tuple(ranges),
        )


def checks_for_device_cards(cards: Iterable[str]) -> dict[str, list[CleaningCheck]]:
    """按【设备卡片】把检查项分组 —— 前端设置面板的 tab 就按这个渲染。

    只返回传进来的卡片(= 工作流里实际连接的设备),未连接的不会出现。
    一个检查项可能落在多个卡片下(如 ``video.frame_drop`` 对每种带摄像头的
    设备都成立),各 tab 里的阈值配置互不影响。

    例::

        checks_for_device_cards(["rgbd_camera", "glove_sensor"])
        → {"rgbd_camera": [video.frame_drop, video.black_screen, ...],
           "glove_sensor": [glove.tactile_health, ...]}
    """
    wanted = [str(card) for card in cards]
    grouped: dict[str, list[CleaningCheck]] = {card: [] for card in wanted}
    for check in all_checks():
        for card in check.device_cards:
            if card in grouped:
                grouped[card].append(check)
    return grouped


def register_check(cls):
    """按 slug 注册检查项(装饰器)。"""
    if not cls.slug:
        raise ValueError(f"Check {cls.__name__} has no slug")
    if cls.slug in _registry:
        raise ValueError(f"Duplicate check slug: {cls.slug}")
    _registry[cls.slug] = cls()
    return cls


def get_check(slug: str) -> CleaningCheck | None:
    return _registry.get(slug)


def all_checks() -> list[CleaningCheck]:
    """全部已注册检查项(按 slug 排序,保证 ruleset_revision 稳定)。"""
    return [_registry[key] for key in sorted(_registry)]


def all_check_specs() -> list[dict]:
    """检查项目录 —— 给 API / UI 用。

    前端配置面板直接渲染这个:按 ``category`` 分组,每个检查项一行开关,
    下面按 ``default_params`` 动态生成输入框。新增检查项前端零改动。
    """
    return [
        {
            "slug": check.slug,
            # 分类取自 slug 前缀(video.frame_drop → video),与 checks/ 下的
            # 目录名一致。前端按它分组。
            "category": check.slug.split(".", 1)[0] if "." in check.slug else "other",
            "label": check.label,
            "version": check.version,
            "description": check.description,
            "requires_channels": list(check.requires_channels),
            "requires_modality": list(check.requires_modality),
            "cross_modal": check.cross_modal,
            "device_cards": list(check.device_cards),
            "default_severity": check.default_severity,
            "default_params": dict(check.default_params),
        }
        for check in all_checks()
    ]


def applicable_checks(channels: Iterable[str],
                      modalities: Iterable[str],
                      *,
                      cross_modal_active: bool = False,
                      enabled: Iterable[str] | None = None,
                      disabled: Iterable[str] | None = None) -> list[CleaningCheck]:
    """筛出该跑哪些检查项。纯函数。

    - ``requires_channels`` / ``requires_modality`` 必须**全部**具备
    - ``cross_modal`` 的额外要求至少两类模态同时存在
    - ``enabled`` / ``disabled`` 是节点配置里的按类型开关
    """
    have_channels = {str(item) for item in channels}
    have_modalities = {str(item) for item in modalities}
    allow = {str(item) for item in enabled} if enabled is not None else None
    deny = {str(item) for item in disabled or ()}

    result: list[CleaningCheck] = []
    for check in all_checks():
        if not set(check.requires_channels) <= have_channels:
            continue
        if not set(check.requires_modality) <= have_modalities:
            continue
        # ★ 设备卡片闸门 —— 声明了归属的检查项只在【对应设备】上跑。
        #
        # 光按信道筛不够:`umi.action_present` 只要"有 action 列"就满足,
        # 而手套项目同样有一列(采集端写的全零占位)—— 结果 UMI 的检查跑到了
        # 手套项目上、报出一个对方根本不关心的失败。
        # 空 device_cards = 跨设备检查,不设闸门。
        if check.device_cards and not (set(check.device_cards) & have_modalities):
            continue
        if check.cross_modal and not cross_modal_active:
            continue
        if allow is not None and check.slug not in allow:
            continue
        if check.slug in deny:
            continue
        result.append(check)
    return result


# 自动发现检查项。
#
# 用 walk_packages 而非 iter_modules —— 前者递归进子包,后者只认平铺的 .py。
# 检查项按数据类型分类放在子包里,目录即分类,便于定位:
#
#     checks/video/frame_drop.py      视频
#     checks/umi/slam_continuity.py   UMI 夹爪
#     checks/glove/baseline_drift.py  触觉手套
#     checks/ego/hand_side.py         EGO 头戴
#     checks/depth/presence.py        深度
#     checks/cross/video_data_sync.py 跨模态
#
# 子包不需要写任何注册代码 —— 空 __init__.py 即可,里面的模块会被自动导入。
# 新增一项检查 = 在对应目录放一个 .py,注册表/节点/前端都不用改。
for _info in pkgutil.walk_packages(__path__, prefix=f"{__name__}."):
    if _info.name.rsplit(".", 1)[-1].startswith("_"):
        continue
    importlib.import_module(_info.name)