Spaces:
Running
Running
stouchinglhh
Claude
sync processor from GitLab c697b6e: 版本 1.19.4 / 前端 1.12.4 —— 服务端 i18n(首屏不再闪英文)、推理模块并入模型页、前端抽出 shared/、缓存键改用前端号
bb59211 Download processor/app/processing/cleaning/trigger.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 10.1 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/processing/cleaning/trigger.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/app/processing/cleaning/trigger.py
-
curl -L -o trigger.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/processing/cleaning/trigger.py
10.1 kB
| """质检触发 —— 把纯引擎接到运行时(工作流跑完之后)。 | |
| 三层分工,别混: | |
| engine.py 纯函数:读证据 → 跑检查 → 出报告。不写盘、不改状态。 | |
| store.py 落盘:写进 episode state 的 ``cleaning_report``,**不碰 status**。 | |
| trigger.py 本文件:从 run 快照里判断"该不该跑、按什么配置跑", | |
| 丢到线程池执行,再把报告交给 store。 | |
| 调用方是 ``api/worker.py`` 的 run 完成回调(与 ``video_quality`` 的门禁任务 | |
| 同一个挂载点)。 | |
| ★ 为什么这里要 `to_thread` | |
| ------------------------- | |
| ``engine.run_checks`` 是**同步**的,而且会读 parquet / 必要时解码视频。直接在 | |
| 协程里调用会把整个事件循环卡住几秒到几十秒(存储根在 NAS 上时更甚,见 | |
| ``routes/pages.py`` 里同样的告诫)。``asyncio.to_thread`` 把它挪到线程池。 | |
| ★ 与 video_quality 门禁的关系 | |
| ---------------------------- | |
| 两者挂在同一张 Data Quality 卡片上,**可能同时触发**(相机类工作流就是): | |
| video_quality_gate → 通过→reviewed / 失败→to_review | |
| cleaning(本模块) → 判 FAIL/ERROR 才拦回 to_review;其余只记录 | |
| 三条路都写同一份 episode state,所以读改写都必须走 | |
| ``localstore.mutate_episode_state``(锁内 RMW),否则后写的一方会把另一方 | |
| 刚写的字段用旧快照冲掉 —— 尤其是 ``status``,被冲掉意味着失败批次看起来 | |
| 像已通过。 | |
| **光推状态还不够**:几条门禁是并发跑的,谁先写没有保证。质检除了推状态, | |
| 还在 ``cleaning_summary.blocking`` 上留一个粘性标记,让 | |
| ``ai_annotation`` / ``video_quality`` 的**自动批准**路径主动让路 —— | |
| 否则质检刚推回 to_review,视频门禁随后又置成 reviewed。 | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| from pathlib import Path | |
| from app.processing.cleaning import engine, store | |
| from app.processing.cleaning.contract import normalize_device_modality | |
| from app.workflow_types import canonical_node_type | |
| # 质检节点在工作流里的 slug。合并前的两个旧 slug(ai_quality_review / | |
| # data_cleaning)由 ``workflow_types.canonical_node_type`` 统一映射到这里。 | |
| _QUALITY_NODE = "data_quality" | |
| def _nodes_by_id(graph: dict) -> dict[str, dict]: | |
| nodes = (graph or {}).get("nodes") or [] | |
| return {str(node.get("id")): node for node in nodes if node.get("id") is not None} | |
| def _incoming_edges(graph: dict) -> dict[str, list[str]]: | |
| """``{目标节点 id: [源节点 id, ...]}``。""" | |
| incoming: dict[str, list[str]] = {} | |
| for edge in (graph or {}).get("edges") or []: | |
| source, target = edge.get("source"), edge.get("target") | |
| if source is None or target is None: | |
| continue | |
| incoming.setdefault(str(target), []).append(str(source)) | |
| return incoming | |
| def _node_type(node: dict | None) -> str: | |
| return canonical_node_type((node or {}).get("data", {}).get("nodeType")) | |
| def _upstream_device_cards(node_id: str, by_id: dict[str, dict], | |
| incoming: dict[str, list[str]]) -> tuple[str, ...]: | |
| """从质检节点往回找,收集上游的**设备卡片**模态。 | |
| 往回找而不是只看直接上游,是因为中间可能夹着处理节点 | |
| (相机 → RGB-D_3D_BareHand → 质检),直接上游是个处理节点、认不出模态。 | |
| 设备卡片就是链路源头,遇到就不再往回走 —— 否则会顺着"卡片A → 卡片B" | |
| 这种不合法的连线继续爬到别处。 | |
| """ | |
| seen: set[str] = set() | |
| queue = list(incoming.get(str(node_id), ())) | |
| cards: list[str] = [] | |
| while queue: | |
| current = queue.pop(0) | |
| if current in seen: | |
| continue | |
| seen.add(current) | |
| modality = normalize_device_modality(_node_type(by_id.get(current))) | |
| if modality: | |
| if modality not in cards: | |
| cards.append(modality) | |
| continue | |
| queue.extend(incoming.get(current, ())) | |
| return tuple(cards) | |
| def data_quality_target(graph: dict, | |
| node_configs: dict) -> tuple[dict, tuple[str, ...]] | None: | |
| """找到**接在数据链路上**的 Data Quality 卡片。 | |
| 返回 ``(卡片配置, 上游设备卡片模态)``;没有卡片、或卡片没接线 → ``None`` | |
| (画布上孤零零摆一张卡片只是配置,不该触发)。 | |
| ★ 判据是"**有入边**",不是列举"哪些源节点算数"。 | |
| ``ai_annotation.video_quality_gate_config`` 用的是后者,它的白名单里 | |
| **没有 ``gripper_device``** —— 于是 UMI 夹爪工作流接上质检卡片后静默 | |
| 地什么都不跑。这种白名单还会继续漏:每加一种设备就漏一次,而且漏了 | |
| 不报错。质检节点的语义本来就与上游是哪类设备无关 —— 它检查的是 | |
| "这个批次目录里实际有什么数据",跑哪些检查由引擎按真实信道决定。 | |
| """ | |
| by_id = _nodes_by_id(graph) | |
| incoming = _incoming_edges(graph) | |
| # 排序保证多张卡片时结果确定,不受画布 JSON 顺序影响 | |
| for node_id in sorted(by_id): | |
| node = by_id[node_id] | |
| if _node_type(node) != _QUALITY_NODE: | |
| continue | |
| if not incoming.get(node_id): | |
| continue # 没接线 —— 只是块配置,不触发 | |
| data = node.get("data") or {} | |
| config = dict(data.get("config") or {}) | |
| config.update((node_configs or {}).get(node.get("id")) or {}) | |
| return config, _upstream_device_cards(node_id, by_id, incoming) | |
| return None | |
| async def run_cleaning_checks(episode_id: str, batch_dir: str | Path, *, | |
| config: dict | None = None, | |
| device_cards: tuple[str, ...] = (), | |
| episode_index: int | None = None, | |
| project: str = "", | |
| probe_video: bool = True) -> dict: | |
| """跑一次质检并落盘,返回四层报告。 | |
| ``episode_index`` 必传 —— 工作流给的 ``batch_dir`` 是**项目目录**,多集 | |
| 共享(见 ``collect.gather_evidence`` 的告诫)。漏传的后果不是报错,而是 | |
| 拿第 0 集的数据去比全部集的视频,得出一堆假的丢帧结论。 | |
| ``probe_video=True`` 是刻意的:只读 parquet 时视频检查拿不到证据, | |
| 报告会被标 ``unchecked`` 并强制 ``passed=False`` —— 每一集都"未通过"的 | |
| 门禁等于没有门禁。代价实测约 4.8s/集(单流 UMI,本机),跑在线程池里。 | |
| """ | |
| report = await asyncio.to_thread( | |
| engine.run_checks, | |
| Path(batch_dir), | |
| episode_id=str(episode_id), | |
| episode_index=episode_index, | |
| project=str(project or ""), | |
| config=config, | |
| probe_video=probe_video, | |
| modality_hint=device_cards or None, | |
| ) | |
| await asyncio.to_thread(store.save_report, str(episode_id), report) | |
| return report | |
| # 后台任务的强引用。 | |
| # | |
| # ``asyncio.create_task`` 的返回值**必须有人持有**:只把它丢给事件循环的话, | |
| # 任务可能在执行到一半时被 GC 回收(asyncio 文档原文:"Save a reference to | |
| # the result of this function, to avoid a task disappearing mid-execution")。 | |
| # 这里持有到它跑完为止。 | |
| _BACKGROUND_TASKS: set[asyncio.Task] = set() | |
| def spawn_cleaning_for_run(run: dict, episode: dict) -> bool: | |
| """worker 完成回调调用的**同步**入口。返回是否已调度。 | |
| 先做图查找再建任务:卡片没接线时连任务都不建(同步、纯字典遍历,很快), | |
| 这样"没有质检节点"这条常见路径不会往事件循环里塞空任务。 | |
| """ | |
| if data_quality_target((run or {}).get("graph") or {}, | |
| (run or {}).get("node_configs") or {}) is None: | |
| return False | |
| task = asyncio.create_task(trigger_cleaning_for_run(run, episode)) | |
| _BACKGROUND_TASKS.add(task) | |
| task.add_done_callback(_BACKGROUND_TASKS.discard) | |
| return True | |
| async def trigger_cleaning_for_run(run: dict, episode: dict) -> dict | None: | |
| """跑一次质检的后台协程。卡片没接线时返回 None。 | |
| **永不抛异常**:这是后台任务,抛出去会变成 unhandled asyncio exception, | |
| 而调用方(worker 的完成响应)已经返回了,没人能处理。失败只记日志 —— | |
| 与 ``video_quality.run_video_quality_review`` 同样的取舍。 | |
| """ | |
| episode_id = str((run or {}).get("episode_id") or (episode or {}).get("id") or "") | |
| batch_dir = (episode or {}).get("path") | |
| if not episode_id or not batch_dir: | |
| return None | |
| try: | |
| target = data_quality_target( | |
| (run or {}).get("graph") or {}, | |
| (run or {}).get("node_configs") or {}, | |
| ) | |
| if target is None: | |
| return None | |
| config, device_cards = target | |
| report = await run_cleaning_checks( | |
| episode_id, batch_dir, | |
| config=config, device_cards=device_cards, | |
| # ``get_episode`` 的记录里 episode_index 可能是 0,不能用 or 兜底 | |
| episode_index=(episode.get("episode_index") | |
| if episode.get("episode_index") is not None else None), | |
| project=str(episode.get("project") or ""), | |
| ) | |
| except Exception as exc: # noqa: BLE001 —— 后台任务不抛 | |
| print(f"[Quality] Data quality checks failed for {episode_id}: " | |
| f"{type(exc).__name__}: {exc}") | |
| return None | |
| summary = store.summary_of(report) | |
| verdict = str(summary.get("status")) | |
| if summary.get("blocking"): | |
| verdict += " -> blocked, back to manual review" | |
| print(f"[Quality] {episode_id}: {verdict} " | |
| f"({summary.get('ranges', 0)} ranges, " | |
| f"{len(device_cards)} device card(s))") | |
| return report | |