File size: 12,973 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
296
297
298
299
300
301
302
303
"""质检引擎 —— 把检查项串成一次运行,产出四层报告。

流程::

    collect.gather_evidence()      读批次目录 → Evidence
            │
            ▼
    applicable_checks()            按实际信道/模态选检查项
            │
            ▼
    逐项 run(ctx)                  产出 Finding
            │
            ▼
    rollup 成 StreamResult        每条流一个结论
            │
            ▼
    report.build_report()          四层结果

**只出结论,不产出数据**:不写文件、不改 episode 状态、不做任何清理动作。
报告由调用方决定怎么用(落盘 / 展示 / 过滤导出)。

设计取舍
--------
* **检查项失败不拖垮整轮** —— 单项抛异常记成 ``ERROR`` 结论继续跑,
  否则一个 bug 会让整集没有报告。
* **不设 `on_failure`** —— 按用户要求简化:清洗只记录,不碰 episode 状态流转。
  需要拦截时由调用方读报告的 ``passed`` / ``verdict`` 自行决定。
"""

from __future__ import annotations

import hashlib
import time
from pathlib import Path
from typing import Any, Iterable

from app.processing.cleaning import collect
from app.processing.cleaning.checks import (
    CheckContext, applicable_checks, all_checks,
)
from app.processing.cleaning.contract import (
    ERROR, PASS, SEVERITY_LABELS, StreamResult, worst,
)
from app.processing.cleaning.report import build_report, empty_report


def ruleset_revision() -> str:
    """规则集指纹 —— 任何检查项的 slug/version 变了它就变。

    用途:报告里带上这个值,规则更新后能识别出"这份报告是用旧规则跑的"。
    与 ``workflow_dispatch.workflow_revision()`` 同款思路 —— 用内容哈希而不是
    自增版本号,省掉版本分配和迁移。
    """
    parts = sorted(f"{check.slug}@{check.version}" for check in all_checks())
    return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:24]


def resolve_config(evidence: collect.Evidence,
                   config: dict | None) -> dict[str, dict]:
    """把节点配置解析成 ``{检查项 slug: {"enabled": bool, "params": {...}}}``。

    节点配置结构(见 data_quality 节点)::

        {
          "devices": {
            "gripper_device": {
              "checks": {
                "umi.slam_continuity": {"enabled": true,
                                        "params": {"max_linear_mps": 3.0}},
                "video.freeze":       {"enabled": false}
              }
            }
          }
        }

    本 episode 有多个设备卡片时,按 ``evidence.modalities`` 的顺序合并;
    **先出现的优先**(后面的不覆盖前面已设的值)。实际采集里一个 episode
    基本只对应一张卡片,多卡片是罕见情况,规则简单可预期即可。

    没配到的检查项走默认:启用、用检查项自己声明的 ``default_params``。
    """
    resolved: dict[str, dict] = {}
    devices = (config or {}).get("devices") or {}
    for modality in evidence.modalities:
        card = devices.get(str(modality)) or {}
        for slug, item in (card.get("checks") or {}).items():
            if slug in resolved:
                continue                    # 先出现的优先
            entry = item if isinstance(item, dict) else {}
            resolved[slug] = {
                "enabled": bool(entry.get("enabled", True)),
                "params": dict(entry.get("params") or {}),
            }
    return resolved


def _context_for(evidence: collect.Evidence, cfg: dict[str, dict]) -> CheckContext:
    """按证据构造检查上下文。阈值按"当前检查项"逐个注入。"""
    return CheckContext(
        batch_dir=evidence.batch_dir,
        channels=evidence.channels,
        modalities=evidence.modalities,
        data_rows=evidence.rows,
        data_columns=evidence.columns,
        duration_sec=evidence.duration_sec,
        fps=evidence.fps,
        streams=evidence.streams,
    )


def _stream_results(evidence: collect.Evidence,
                    findings: Iterable) -> list[StreamResult]:
    """把 findings 归拢成"每条流一个结论" —— 报告的第一层。"""
    by_stream: dict[str, list] = {}
    for item in findings:
        by_stream.setdefault(item.stream or "", []).append(item)

    results: list[StreamResult] = []
    seen: set[str] = set()
    for stream in evidence.streams:
        items = by_stream.get(stream.key) or []
        seen.add(stream.key)
        status = worst(item.status for item in items) if items else PASS
        results.append(StreamResult(
            stream=stream.key,
            modality=",".join(evidence.modalities),
            label=stream.key,
            channels=(stream.channel,) if stream.channel else (),
            status=status,
            reasons=tuple(item.rule for item in items if item.status != PASS),
            metrics={k: v for item in items
                     for k, v in (item.metrics or {}).items()
                     if isinstance(v, (int, float, str, bool))},
            ranges=tuple(r for item in items for r in (item.ranges or ())),
        ))

    # 没有对应证据流的 finding(如整集级结论)也要留在第一层里
    for key, items in by_stream.items():
        if key in seen:
            continue
        results.append(StreamResult(
            stream=key or "episode",
            status=worst(item.status for item in items),
            reasons=tuple(item.rule for item in items if item.status != PASS),
            ranges=tuple(r for item in items for r in (item.ranges or ())),
        ))
    return results


def run_checks(batch_dir: Path, *,
               episode_id: str = "",
               episode_index: int | None = None,
               project: str = "",
               config: dict | None = None,
               probe_video: bool = False,
               modality_hint: str | Iterable[str] | None = None) -> dict:
    """跑一个 episode 的全部适用检查,返回四层报告(纯 dict,不含副作用)。

    ``modality_hint`` 是设备模态(可多个,来自工作流连线的设备卡片)。给了它,
    ``resolve_config`` 才能查到用户在设置面板里按设备卡片配的阈值;给不出就
    走数据推断(推断不出 ``stereo_*``,那些 tab 的配置会被静默忽略)。

    ``episode_index`` 只在【一个文件装多集】的布局下需要(LeRobot v3.0 导出
    产物就是这种);canonical 布局一集一文件,留空即可。**传错会导致把整份
    文件当成一集读**,所有指标因此失真 —— 而 ``localstore.get_episode()``
    返回的 ``path`` 是**项目目录**(多集共享),所以工作流路径**必须**带上它。

    ``probe_video=False``(默认)只读 parquet,毫秒级;视频检查要解码抽样,
    每集约几秒到几十秒,按需开。
    """
    started = time.perf_counter()
    evidence = collect.gather_evidence(
        batch_dir, episode_index=episode_index,
        probe_video=probe_video, modality_hint=modality_hint)

    if not evidence.streams:
        return empty_report(
            episode_id=episode_id, project=project,
            reason=str(evidence.notes.get("reason") or "no_data"))

    resolved = resolve_config(evidence, config)
    ctx = _context_for(evidence, resolved)

    # ★ 配置是【黑名单】,**不是白名单** —— 不传 ``enabled``。
    #
    # ``applicable_checks`` 的 ``enabled`` 是白名单语义(``slug not in allow``
    # 就跳过)。而前端设置面板只在用户**碰过**的检查项上写 entry(见
    # DeviceQualityModal 的 setEntry,未触碰的项不落进 config),所以
    # ``resolved`` 里通常只有一两条。把它当白名单 = 「用户在弹窗里改了一个
    # 阈值 → 其余检查项全部静默停跑」:实测只配 umi.gripper_range 时,
    # 9 项变 1 项,且不报任何错。
    #
    # 检查项没有"默认关闭"的概念(CleaningCheck 无 default_enabled),
    # 适用即跑;配置唯一的语义就是**关掉某项**。
    #
    # (曾经写成 `{...enabled} or None`:那个 ``or None`` 只修了空集那半边
    #   ——"空集=一个都不跑"——非空的白名单照样是错的。整条白名单都不该有。)
    deny = {slug for slug, item in resolved.items() if not item["enabled"]} or None

    checks = applicable_checks(
        channels=evidence.channels,
        modalities=evidence.modalities,
        cross_modal_active=len(set(evidence.modalities)) >= 2
                          or len({item.channel for item in evidence.streams}) >= 2,
        disabled=deny,
    )

    findings: list = []
    failed_checks: list[str] = []
    for check in checks:
        entry = resolved.get(check.slug) or {}
        ctx.params = dict(entry.get("params") or {})
        try:
            findings.extend(check.run(ctx) or [])
        except Exception as exc:
            failed_checks.append(f"{check.slug}: {exc}")
            findings.append(_error_finding(check, exc))

    # 视频流登记了却没检查 —— 必须显式记一笔,但**不能混进 findings**。
    #
    # 视频检查项"适用"(rgb 信道存在)不等于"查过":``probe_video=False`` 时
    # 它们拿到的是空报告,内部直接 continue,一条结论都不产出。报告若就此收尾,
    # 会得出 ``passed=True 全部通过`` —— 而实际上视频根本没看。
    #
    # ★ 但它不该混进 findings 的严重度排序:``PENDING`` 排在 ``WARN`` 之前,
    #   一条"视频未检查"会把真实的力漂移警告盖掉(实测 52 集全被标成"待人工
    #   确认")。"覆盖度"和"数据有多坏"是两个维度 —— 分开报。
    unchecked: list[dict] = []
    if not probe_video:
        rgb_streams = [item for item in evidence.streams if item.channel == "rgb"]
        if rgb_streams:
            unchecked.append({
                "channel": "rgb",
                "reason": "probe_video_disabled",
                "message": f"{len(rgb_streams)} 路视频未检查(本轮只读了 parquet)",
                "streams": [item.key for item in rgb_streams],
            })

    elapsed_ms = int((time.perf_counter() - started) * 1000)
    report = build_report(
        _stream_results(evidence, findings),
        findings,
        episode_id=episode_id,
        project=project,
        modalities=_modality_summary(evidence, [c.slug for c in checks]),
        channels=evidence.channels,
        ruleset_revision=ruleset_revision(),
        template_source=modality_hint or "",
        elapsed_ms=elapsed_ms,
    )

    report["unchecked"] = unchecked

    if not checks:
        # 一个检查项都没跑 —— 报告在说"没问题",而实际是"没查"。
        # 与 ``empty_report`` 同一条原则:没有结论不等于结论合格(fail closed)。
        report["no_checks_ran"] = True

    # ``episode`` 只反映【数据本身】的结论;``passed`` 额外要求没有未检查项。
    #
    # 分开的理由:前者是"数据有多坏",后者是"我们看全了没有"。混在一起会让
    # "视频没检查"盖过"力漂移",也会让"数据确实没问题但没看全"看起来像数据坏了。
    if unchecked or not checks:
        report["passed"] = False

    if failed_checks:
        # 检查项本身出错不改判定 —— 但要让人看见,否则会误以为"这集没问题"
        report["check_errors"] = failed_checks
    return report


def _modality_summary(evidence: collect.Evidence,
                      checks_run: Iterable[str]) -> dict:
    """PDF §1 的「数据清单」—— 这台设备是什么、有哪些信道、**实际跑了**哪些检查。

    ``checks_run`` 必须是【真正执行了】的检查项(``applicable_checks`` 的结果),
    不是配置里写的那份。两者可能差很远:设备卡片闸门会把不属于本设备的检查项
    滤掉,而报告如果只报配置,就分不清"检查了没问题"和"压根没检查"。
    """
    return {
        "device_cards": list(evidence.modalities),
        "channels": sorted(evidence.channels),
        "rows": evidence.rows,
        "fps": evidence.fps,
        "parquet": evidence.parquet,
        "checks_run": sorted(checks_run),
    }


def _error_finding(check, exc: Exception):
    """检查项抛异常时的占位结论。

    用 ``ERROR``(处理失败)而不是 ``FAIL`` —— 这是"没检查成",
    不是"数据坏了"。混为一谈会让数据质量问题看起来比实际多。
    """
    from app.processing.cleaning.contract import Finding

    return Finding(
        rule=check.slug, version=check.version, status=ERROR,
        scope="episode",
        message=f"check failed to run: {type(exc).__name__}: {exc}",
    )