diff --git a/.gitattributes b/.gitattributes index fb3a8d58506e0b3d8bc3ec8ac1f2e1a740c7b9aa..4c502e1e9af9541cf2ff5f505be9a13ad6d62a5a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -44,3 +44,5 @@ collector/**/*.task filter=lfs diff=lfs merge=lfs -text # processor 文档截图走 LFS(HF 拒绝非 LFS 二进制) processor/**/*.png filter=lfs diff=lfs merge=lfs -text +# MediaPipe 手部模型(7.5MB 二进制)—— 与 collector 侧同一条规则,HF 不收非 LFS 二进制 +processor/**/*.task filter=lfs diff=lfs merge=lfs -text diff --git a/processor/.env.example b/processor/.env.example index d1e5b71472a0fd365de6735b419012b3d62088ba..a2b8ae2dec15446428083611b6bba8d1291b4d64 100644 --- a/processor/.env.example +++ b/processor/.env.example @@ -13,8 +13,8 @@ SFTP_CONNECT_TIMEOUT=15 API_KEY=change-me WORKER_API_KEY=change-me-worker-key JWT_SECRET=change-me-jwt-secret -EGODATA_BOOTSTRAP_USERNAME=demo-admin -EGODATA_BOOTSTRAP_PASSWORD=change-me-bootstrap-password +EGODATA_BOOTSTRAP_USERNAME=Root +EGODATA_BOOTSTRAP_PASSWORD=Root EGODATA_BOOTSTRAP_EMAIL=root@egodata.local DEFAULT_WORKFLOW_ID= WORKER_LEASE_SECONDS=90 diff --git a/processor/.gitignore b/processor/.gitignore index e3510ef52b3fc7c00802993e0486623d19168a4e..024d26279fcf995a6289c1c2e392fcc2f2ee432b 100644 --- a/processor/.gitignore +++ b/processor/.gitignore @@ -33,6 +33,14 @@ web/workflow-studio/*.tsbuildinfo *.db *.log +# 编辑器/工具留下的备份与冲突残留文件 —— 一律不入库。 +# 起因:`web/static/plyr.js.bak`(改 plyr.js 时留下的备份)被误提交进了版本库, +# 而仓库里没有任何规则拦它。改 JS 时留的 .bak 尤其容易混进来。 +*.bak +*.orig +*.rej +*~ + # Local model assets and scratch (large binaries, never in git). models/ *.deb diff --git a/processor/README.md b/processor/README.md index 420f2d33fc4c6786b8f16b789427469dda2779f9..c9b5bfc66cb2660b3df2c2cdda7f82c150d9d862 100644 --- a/processor/README.md +++ b/processor/README.md @@ -4,7 +4,7 @@ [English](README.md) | [中文](README.zh-CN.md) -> Documentation: 2026-09-02 · Backend: `1.6.8` · Web frontend: `1.5.6` +> Documentation: 2026-09-28 · Backend: `1.19.4` · Frontend: `1.12.3` processor is EgoData's service for data capture, processing, review, and export. It is designed for embodied AI and robot manipulation data. The system consists of a FastAPI backend, @@ -523,12 +523,22 @@ Troubleshooting order:
Click to expand: version maintenance rules -- The backend version is maintained in `app/version.py`; -- the Web frontend version is maintained in `web/workflow-studio/package.json`; +- The backend version is maintained in `app/version.py` and covers the backend **only**; +- **the frontend version is maintained in `web/VERSION`**. It covers every piece of frontend + code under `processor/web/` (`templates/`, `static/`, `workflow-studio/`) — **not just the + React SPA**. The version inside `web/workflow-studio/package.json` only represents the SPA + itself; all three numbers are independent and do not need to stay in sync. +- **Bump `web/VERSION` — that one file — whenever you change the frontend.** A single number + serves two purposes: ① the `?v={{ asset_version }}` cache key on static assets (so browsers + re-fetch changed JS/CSS), ② the "frontend X" part of commit messages. Sharing one number + means the commit message can never disagree with what the browser actually loaded. + You do **not** need to touch the backend version for a frontend change. +- `web/VERSION` is read once at startup and cached (`app/web_version.py`), so **restart the + service** after bumping it. - increment the patch version for fixes and small adjustments, the minor version for new features, and the major version for architectural changes; - before release, record the software version, data format, and API changes together. -Current versions: backend `1.6.8`, Web frontend `1.5.6`. +Current versions: backend `1.19.4`, frontend `1.12.3`.
diff --git a/processor/README.zh-CN.md b/processor/README.zh-CN.md index 363a79a0cd2046d6a1b0b7d0753c1f2914da90f4..681f290531e07cbdcd2f7b67289872d988238eb3 100644 --- a/processor/README.zh-CN.md +++ b/processor/README.zh-CN.md @@ -2,7 +2,7 @@ [中文](README.zh-CN.md) | [English](README.md) -> 文档版本:2026-09-02 · 后端版本:`1.6.8` · WEB 前端版本:`1.5.6` +> 文档版本:2026-09-28 · 后端版本:`1.19.4` · 前端版本:`1.12.3` processor 是 EgoData 的数据采集、处理、审核和导出服务,面向具身智能与机器人 操作数据。系统由 FastAPI 后端、异步 Worker、Web 审核页面和 Workflow Studio 组成。 @@ -506,11 +506,19 @@ npm run build
点击展开:版本维护规则 -- 后端版本在 `app/version.py` 中维护; -- Web 前端版本在 `web/workflow-studio/package.json` 中维护; +- 后端版本在 `app/version.py` 中维护,**只管后端**; +- **前端版本在 `web/VERSION` 中维护**。它覆盖 `processor/web/` 下的全部前端代码 + (`templates/`、`static/`、`workflow-studio/`),**不只是那个 React SPA**。 + `web/workflow-studio/package.json` 里的 version 只代表 SPA 自己,三者互相独立、不必同步。 +- **改了前端就只 bump `web/VERSION` 这一个文件**。它一个号管两件事: + ① 模板里的 `?v={{ asset_version }}` 缓存键(读了它浏览器才会重新拉 JS/CSS) + ② 提交信息里的「前端 X」。 + 两个用途共用一个数,不会出现"提交信息写 1.12.3、浏览器却拿 1.12.2"这种对不上的情况。 + 改前端**不需要**动后端版本号。 +- 改完 `web/VERSION` 要**重启服务**才生效(`app/web_version.py` 启动时读一次就缓存)。 - 修复和小调整增加修订号;新增功能增加次版本号;重大架构变更增加主版本号; - 发布前应同步记录软件版本、数据格式和 API 变化。 -当前版本:后端 `1.6.8`,WEB 前端 `1.5.6`。 +当前版本:后端 `1.19.4`,前端 `1.12.3`。
diff --git a/processor/app/ai_annotation.py b/processor/app/ai_annotation.py index 1a4e16cb15febd3454ce070182d0240b0b28bb1f..7430a4dc79e6a7454c99b334591156faf4ba9999 100644 --- a/processor/app/ai_annotation.py +++ b/processor/app/ai_annotation.py @@ -41,7 +41,6 @@ from app.localstore import ( read_episode_state, write_episode_state, ) -from app.routes.annotations import notify_annotations_changed from app.video_quality import check_video_quality_async from app.workflow_types import HAND_PROCESS_TYPES, canonical_node_type @@ -612,23 +611,36 @@ def _set_ai_quality_state(episode_id: str, report: dict, *, The web UI continues to use only Reviewing/Approved. The detailed report is kept in episode state for diagnostics and future review tooling. + + ★ ``auto_approve`` 会被【数据质检】否决 —— 判 FAIL/ERROR 的批次在 + ``cleaning_summary.blocking`` 上留了粘性标记。AI 标注覆盖率合格不代表 + 数据本身没问题;只看自己那份报告的话,坏数据会被一路自动批准放行 + (质检与视频门禁是并发跑的,谁先写没有保证,所以必须查标记而不是 + 比谁写得晚)。 """ - state = read_episode_state(episode_id) - state["ai_quality_status"] = "passed" if report.get("passed") else "failed" - state["ai_quality_report"] = report - state["updated_at"] = _utcnow() - current = str(state.get("status") or "") - if report.get("passed") and auto_approve: - if current in {"processing", "to_review", "completed", "failed"}: - state["status"] = "reviewed" - if not state.get("approved_at"): - state["approved_at"] = _utcnow() - elif not report.get("passed"): - # Fail closed: an incomplete/failed AI result remains in Reviewing. - if current in {"processing", "completed", "reviewed", "approved", "failed"}: - state["status"] = "to_review" - state["approved_at"] = None - write_episode_state(episode_id, state) + from app.localstore import mutate_episode_state + from app.processing.cleaning.store import is_approval_blocked + + def _apply(state: dict) -> None: + state["ai_quality_status"] = "passed" if report.get("passed") else "failed" + state["ai_quality_report"] = report + state["updated_at"] = _utcnow() + current = str(state.get("status") or "") + if report.get("passed") and auto_approve: + if (current in {"processing", "to_review", "completed", "failed"} + and not is_approval_blocked(state)): + state["status"] = "reviewed" + if not state.get("approved_at"): + state["approved_at"] = _utcnow() + elif not report.get("passed"): + # Fail closed: an incomplete/failed AI result remains in Reviewing. + if current in {"processing", "completed", "reviewed", "approved", "failed"}: + state["status"] = "to_review" + state["approved_at"] = None + + # 锁内 RMW:数据质检 / 视频门禁在同一次 run 后并发写同一份 state。分开 + # read→write 的话,后写的会用旧快照把先写的 ``status`` 冲掉。 + mutate_episode_state(episode_id, _apply) def _set_ai_quality_pending(episode_id: str) -> None: @@ -2697,6 +2709,11 @@ def _persist_ai_segment(episode_id: str, generation: int, candidate: dict, index: int, media_sources: list[str], *, pending: bool = False, error: str = "") -> dict: """Atomically insert/update one segment without waiting for its siblings.""" + # 惰性导入 —— 不要提到模块顶层。本模块在路由层**之下**,而 routes.annotations + # 反过来惰性引用 ai_annotation(_schedule_dataset_sync)。模块级导入会形成 + # import 环:谁先被导入,谁就拿不到对方完整的模块对象。放进调用点后方向保持 + # 单向(路由 → 本模块),将来搬文件也不会因顺序踩坑。 + from app.routes.annotations import notify_annotations_changed record = _ai_annotation_record( episode_id, candidate, index, media_sources, pending=pending, error=error) @@ -2823,6 +2840,8 @@ def run_ai_annotation(episode_id: str, mode: str, min_confidence: float, 走本地 vLLM 抽帧图;provider=api 走 Kimi 原生视频 —— 两模式严格 分离,配置缺失/请求失败显式报错(任务 detail 可见),绝不互回退。 """ + # 同 _persist_ai_segment:惰性导入,避免与 routes.annotations 形成 import 环。 + from app.routes.annotations import notify_annotations_changed episode_id = str(episode_id) quality_review_enabled = bool(quality_gate or video_quality_gate) if quality_review_enabled: @@ -3538,7 +3557,7 @@ def _connected_quality_node_config(graph: dict, node_configs: dict, } quality_nodes = { str(node.get("id")): node for node in nodes - if canonical_node_type((node.get("data") or {}).get("nodeType")) == "ai_quality_review" + if canonical_node_type((node.get("data") or {}).get("nodeType")) == "data_quality" and node.get("id") is not None } if not source_ids or not quality_nodes: @@ -3570,25 +3589,32 @@ def _connected_quality_node_config(graph: dict, node_configs: dict, return None -def ai_quality_review_node_config(graph: dict, node_configs: dict) -> dict | None: - """Return the quality card only when it is connected to AI Annotation. +def annotation_coverage_gate_config(graph: dict, node_configs: dict) -> dict | None: + """Return the Data Quality card only when it is connected to AI Annotation. A card sitting alone in the canvas is configuration only. The gate is - enabled for a directed path ``AI Annotation -> ... -> AI Quality Review``; + enabled for a directed path ``AI Annotation -> ... -> Data Quality``; allowing pass-through nodes in the middle keeps existing workflow layouts compatible while making a dangling quality card inert. + + 以前叫 ``ai_quality_review_node_config`` —— 当时节点叫 AI Quality Review。 + 节点合并为 Data Quality 后按"它能开启哪项检查"重命名,函数名不再绑定 + 已废弃的 slug。 """ return _connected_quality_node_config( graph, node_configs, {"ai_annotation"}) -def video_quality_review_node_config(graph: dict, node_configs: dict) -> dict | None: - """Return the quality card when it is connected to a media/data source. +def video_quality_gate_config(graph: dict, node_configs: dict) -> dict | None: + """Return the Data Quality card when it is connected to a media/data source. This enables a video-only workflow to run the post-processing media gate, - while an unconnected ``AI Quality Review`` card remains inert. The list + while an unconnected ``Data Quality`` card remains inert. The list intentionally contains existing input/process node types only; export nodes cannot accidentally activate a review gate. + + 历史上这段逻辑属于 ``AI Quality Review`` 卡片 —— 视频检查当时"寄居"在 + 标注节点里,好让没有 AI 标注的纯视频工作流也有质量门禁。 """ return _connected_quality_node_config( graph, @@ -3656,7 +3682,7 @@ def _workflow_ai_quality_enabled(episode_id: str) -> bool: wf = get_workflow(wf_id) if wf is None: continue - if ai_quality_review_node_config( + if annotation_coverage_gate_config( wf.get("graph") or {}, wf.get("node_configs") or {}) is not None: return True return False @@ -3683,7 +3709,7 @@ def _workflow_video_quality_enabled(episode_id: str) -> bool: wf = get_workflow(wf_id) if wf is None: continue - if video_quality_review_node_config( + if video_quality_gate_config( wf.get("graph") or {}, wf.get("node_configs") or {}) is not None: return True return False diff --git a/processor/app/api/quality.py b/processor/app/api/quality.py new file mode 100644 index 0000000000000000000000000000000000000000..2e7295582ecec907e84bbf90a61fb7400448c9f1 --- /dev/null +++ b/processor/app/api/quality.py @@ -0,0 +1,172 @@ +"""数据质检 API —— 检查项目录、报告查询、手动触发。 + +给前端两件事: +* 设置面板的**数据源** —— ``/checks`` 返回检查项目录 + 按设备卡片的分组, + 面板按它渲染 tab 和每一项的阈值输入框。新增检查项前端零改动。 +* 报告的**读取入口** —— 列表页和详情页。 + +写端点少而克制:只有"重跑某集"。质检本身不做任何数据修改, +所以没有删除/清理类的接口。 +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query + +router = APIRouter(prefix="/api/v1/quality", tags=["quality"]) + + +def _check_catalog() -> dict: + """检查项目录 + 按设备卡片分组 —— 前端设置面板的渲染数据。 + + ``checks`` 是平铺清单(每项带 ``device_cards`` / ``default_params``); + ``by_device`` 是按设备卡片分好组的视图,前端 tab 直接用。 + 两者给同一份数据,前端按方便取用。 + """ + from app.processing.cleaning.checks import all_checks, all_check_specs + from app.processing.cleaning.contract import ( + DEVICE_MODALITIES, MODALITY_CHANNELS, + ) + from app.processing.cleaning.engine import ruleset_revision + + specs = all_check_specs() + by_device: dict[str, list[str]] = {} + for modality in DEVICE_MODALITIES: + by_device[modality] = [ + spec["slug"] for spec in specs + if modality in (spec.get("device_cards") or []) + ] + + return { + "ruleset_revision": ruleset_revision(), + "checks": specs, + "by_device": by_device, + "device_labels": _device_labels(), + "device_channels": { + modality: list(channels) + for modality, channels in MODALITY_CHANNELS.items() + }, + "severity_labels": _severity_labels(), + "total": len(all_checks()), + } + + +def _device_labels() -> dict[str, str]: + """设备卡片的显示名 —— 复用 device_naming,不另起一套。""" + try: + from app.device_naming import DEVICE_LABELS + return dict(DEVICE_LABELS) + except Exception: + return {} + + +def _severity_labels() -> dict[str, str]: + from app.processing.cleaning.contract import SEVERITY_LABELS + return dict(SEVERITY_LABELS) + + +@router.get("/checks") +def list_checks() -> dict: + """检查项目录(含按设备卡片分组)—— 设置面板用。""" + return _check_catalog() + + +@router.get("/summary") +def list_summaries(project: str | None = Query(None), + status: str | None = Query(None), + limit: int = Query(200, ge=1, le=2000)) -> dict: + """质检报告列表(摘要),按时间倒序。 + + ``status`` 过滤按 episode 结论(pass / warn / fail / pending_review / error)。 + """ + from app.processing.cleaning.store import list_reports + + items = list_reports() + if project: + items = [item for item in items if str(item.get("project")) == project] + if status: + items = [item for item in items if item.get("status") == status] + + counts: dict[str, int] = {} + for item in items: + key = str(item.get("status") or "unknown") + counts[key] = counts.get(key, 0) + 1 + + return {"reports": items[:limit], "total": len(items), "counts": counts} + + +@router.get("/episodes/{episode_id}") +def get_report(episode_id: str) -> dict: + """某一集的完整质检报告(四层结果)。""" + from app.processing.cleaning.store import load_report, load_summary + + report = load_report(episode_id) + if report is not None: + return report + summary = load_summary(episode_id) + if summary is not None: + # 完整报告被清掉了但摘要还在(state 里)—— 至少把摘要给出去 + return {"episode_id": episode_id, "summary_only": True, **summary} + raise HTTPException(status_code=404, detail="No quality report for this episode") + + +@router.post("/episodes/{episode_id}/run") +async def run_episode_checks( + episode_id: str, + probe_video: bool = Query(False, description="是否跑视频检查(慢)"), + persist: bool = Query(True, description="是否把报告写进 episode state"), +) -> dict: + """重跑某一集的质检。 + + 跑在**线程池**里 —— 读 parquet/视频是阻塞 IO,直接放事件循环上会把 + 页面请求卡住(仓库里 ``routes/devices.py`` 有同样的处理)。 + """ + episode = await asyncio.to_thread(_find_episode, episode_id) + if episode is None: + raise HTTPException(status_code=404, detail="Episode not found") + + report = await asyncio.to_thread(_run_checks_for, episode, probe_video) + saved = False + if persist: + from app.processing.cleaning.store import save_report + saved = await asyncio.to_thread(save_report, episode_id, report) + return {"episode_id": episode_id, "saved": saved, "report": report} + + +def _find_episode(episode_id: str) -> dict | None: + """在会话索引里找这一集(含 ``path``,质检要用它读数据)。""" + from app.localstore import scan_sessions + + for episode in scan_sessions(): + if str(episode.get("id")) == episode_id: + return episode + return None + + +def _run_checks_for(episode: dict, probe_video: bool) -> dict: + from app.processing.cleaning.engine import run_checks + + path = Path(str(episode.get("path") or "")) + if not path.is_dir(): + raise HTTPException(status_code=409, detail="Episode data directory missing") + + # 设备卡片取自 episode 的设备信息 —— 与工作流派发用的是同一套口径 + modality = None + try: + from app.workflow_dispatch import _episode_input_groups + groups = _episode_input_groups(episode) or [] + if groups: + modality = str(groups[0].get("input_type") or "") or None + except Exception: + modality = None + + return run_checks( + path, + episode_id=str(episode.get("id") or ""), + project=str(episode.get("project") or ""), + probe_video=probe_video, + modality_hint=modality, + ) diff --git a/processor/app/api/worker.py b/processor/app/api/worker.py index ad33512b3ced98af2dc0826cd9c46fc38bb99570..a68cd156a536d5465b1dfd0d07a3300fd0047071 100644 --- a/processor/app/api/worker.py +++ b/processor/app/api/worker.py @@ -520,14 +520,14 @@ async def complete_job( from app.ai_annotation import ( run_ai_annotation, ai_annotation_node_config, - ai_quality_review_node_config, - video_quality_review_node_config, + annotation_coverage_gate_config, + video_quality_gate_config, ) graph = run.get("graph") or {} node_configs = run.get("node_configs") or {} cfg = ai_annotation_node_config(graph, node_configs) - quality_cfg = ai_quality_review_node_config(graph, node_configs) - video_quality_cfg = video_quality_review_node_config(graph, node_configs) + quality_cfg = annotation_coverage_gate_config(graph, node_configs) + video_quality_cfg = video_quality_gate_config(graph, node_configs) if cfg is not None and not run.get("ai_suggest_triggered"): run["ai_suggest_triggered"] = True save_run(run) @@ -572,6 +572,26 @@ async def complete_job( except Exception as exc: print(f"[Worker] AI annotation trigger skipped: {exc}") + # 数据质检联动:工作流里 Data Quality 卡片**接了数据链路**就跑一次检查。 + # + # 与上面两条是两套东西:ai_annotation / video_quality 会改 episode 状态 + # (通过→reviewed、失败→卡到 to_review),而质检**只把结论记进** + # state 的 cleaning_report,不参与状态流转。 + # + # 两者会同时触发(相机类工作流就同时命中 video_quality_gate 和这里),而且 + # 都各要解码一遍视频 —— 所以它们几乎同时结束、同时写状态,状态写入一律走 + # localstore.mutate_episode_state(锁内 RMW),否则后写的会用旧快照把先写的 + # 冲掉(尤其 status)。 + # + # 必须放后台任务:probe_video 要解码视频(实测约 4.8s/集),不能拖住完成响应。 + try: + from app.processing.cleaning.trigger import spawn_cleaning_for_run + if ep is not None and spawn_cleaning_for_run(run, ep): + print(f"[Worker] Data quality checks triggered " + f"for {run['episode_id']}") + except Exception as exc: + print(f"[Worker] Data quality trigger skipped: {exc}") + # 归档联动:处理完成后后台把该批次增量推送到 NAS(只推不删;rsync # 逐块校验+断点续传,失败仅记日志不阻塞响应;每晚 cron 兜底补传)。 # 脚本不存在(如 Windows 环境)时静默跳过。 diff --git a/processor/app/cleaning.py b/processor/app/cleaning.py index 57ec0257631adb872e260d667e4d6dd9740674c0..5bc62c4bf71dd06aabeae4fa443a35e98b0f986a 100644 --- a/processor/app/cleaning.py +++ b/processor/app/cleaning.py @@ -281,20 +281,41 @@ def _check_frame_alignment_fs(batch_dir: Path) -> dict: except Exception: continue parquet_frames = len(seen) + if parquet_frames == 0: + return {"name": "frame_alignment", "passed": True, "detail": "No parquet data"} + + # 2026-09-23 修:此处此前直接用 cv2 却没 import,异常被下方 except 吞掉, + # 整个帧对齐检查在文件级路径下**永远静默通过**(detail 里写的是 + # "Check error (skipped)" 但 passed=True,不会引起任何人注意)。 + try: + import cv2 + except ImportError: + return {"name": "frame_alignment", "passed": True, + "detail": "OpenCV not available — video frame count skipped"} + + mp4s = sorted(batch_dir.rglob("*.mp4")) + if not mp4s: + # 与 _check_video_decode_fs 同款处理:没有视频就没有可比对的对象, + # 判"未适用"而不是"不匹配"——只有传感器的批次(如纯手套)是合法的。 + # 修复 cv2 漏 import 后,这里若不给守卫会退化成 dev=10000% 的误报。 + return {"name": "frame_alignment", "passed": True, + "detail": "No video to compare against"} + video_frames = 0 - for mp4 in batch_dir.rglob("*.mp4"): + for mp4 in mp4s: cap = cv2.VideoCapture(str(mp4)) try: video_frames = max(video_frames, int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0) finally: cap.release() - if parquet_frames == 0: - return {"name": "frame_alignment", "passed": True, "detail": "No parquet data"} ratio = abs(parquet_frames - video_frames) / max(video_frames, 1) return {"name": "frame_alignment", "passed": ratio < 0.10, "detail": f"parquet={parquet_frames}, video={video_frames}, dev={ratio:.1%}"} except Exception as e: - return {"name": "frame_alignment", "passed": True, "detail": f"Check error (skipped): {e}"} + # 保持 passed=True(不改变既有行为),但 detail 必须说清是"降级通过", + # 免得再出现"看起来全过了其实根本没检查"的情况。 + return {"name": "frame_alignment", "passed": True, + "detail": f"Degraded pass — check errored: {e}"} def _check_sensor_completeness_fs(batch_dir: Path) -> dict: diff --git a/processor/app/config.py b/processor/app/config.py index 395f3a444dd71fa9e0ad2a7faef496acdeba0ee1..14d51d89ab6312b606d7ff8dc649af59625c4ec4 100644 --- a/processor/app/config.py +++ b/processor/app/config.py @@ -56,6 +56,27 @@ class Settings(BaseSettings): EXPORT_SHARD_SIZE: int = 100000 VIDEO_SHARD_FRAMES: int = 5000 + # Training. Checkpoints are produced *outside* the platform by the official + # ``lerobot-train`` CLI (conda env ``lerobot052_base_piper``), so this points + # at an existing directory we only ever read. Empty = use the default below. + TRAIN_OUTPUT_ROOT: str = "" + + @property + def train_root(self) -> Path: + """Trained-model directory — **read-only**, never created here. + + Defaults to ``/文件测试/umi_act_model``, i.e. beside ``processor/``. + The directory is git-ignored and may legitimately be absent (fresh + clone, or training never run on this host): callers must treat a + missing path as "no runs" rather than creating it. Creating it would + make an empty training dir appear out of nowhere on every boot. + """ + raw = str(self.TRAIN_OUTPUT_ROOT or "").strip() + if raw: + return Path(raw).expanduser() + return (Path(__file__).resolve().parent.parent.parent + / "文件测试" / "umi_act_model") + model_config = { "env_prefix": "", "case_sensitive": False, diff --git a/processor/app/hdf5_export.py b/processor/app/hdf5_export.py index d5b4538925b7cd2f01691070ed48c3547a8dab25..45a9a1fec2d71254fef2eedd7f34a3b9f9f44e1b 100644 --- a/processor/app/hdf5_export.py +++ b/processor/app/hdf5_export.py @@ -34,6 +34,7 @@ from app.localstore import get_episode from app.lerobot_export import ( _annotation_maps, _calibration_docs, + _depth_video_paths, _hand_columns, _infer_hand_3d_unit, _key_matches_any, @@ -288,6 +289,66 @@ def build_hdf5_dataset(dataset_name: str, episode_ids: list[str], output_dir: Pa "video_info": {"video.fps": vfps, "video.is_depth_map": False}, } + # 1b) 深度流 → 逐帧 uint16 毫米(单独一组,不和 RGB 混在 images/ 下) + # + # 深度**不在** camera_streams 里 —— 它单独放在 depth//, + # 所以上面的 RGB 循环根本看不到它,必须用 lerobot_export 的发现函数。 + # + # 解码必须走 DepthVideoReader(ffmpeg 解 gray12le):OpenCV 会把 + # 12-bit 深度当普通显示图转成 8-bit,真实毫米值全丢。 + # + # **0 表示"没测到深度"的无效像素,不是 0 毫米** —— 与采集端语义一致, + # 原样保留,不要当数值去算均值。 + for source_key, depth_path in _depth_video_paths(session_dir, episode_id): + if include_video_keys is not None and not _key_matches_any( + source_key, include_video_keys): + continue + reader = None + try: + from app.lerobot_v21 import DEPTH_VIDEO_ENCODING, DepthVideoReader + reader = DepthVideoReader(depth_path) + dheight, dwidth = reader.height, reader.width + if dheight <= 0 or dwidth <= 0 or T <= 0: + continue + dfps = float(reader.fps or episode_fps) + ds = obs.create_dataset( + f"depth/{source_key}", shape=(T, dheight, dwidth), + dtype="uint16", chunks=(1, dheight, dwidth), + compression=compression, compression_opts=level) + ds.attrs["unit"] = "millimetre" + ds.attrs["invalid_value"] = 0 + ds.attrs["fps"] = dfps + ds.attrs["source_key"] = source_key + ds.attrs["source_episode"] = episode_id + ds.attrs["encoding"] = DEPTH_VIDEO_ENCODING + i = 0 + while i < T: + frame = reader.read_mm() + if frame is None: + break + ds[i] = frame + i += 1 + features[f"observation.depth.{source_key}"] = { + "dtype": "uint16", "shape": [T, dheight, dwidth], + "unit": "millimetre", + "note": "0 = 无效像素(该点未测到深度),不是 0 毫米", + "video_info": { + "video.fps": dfps, + "video.is_depth_map": True, + "video.height": dheight, + "video.width": dwidth, + }, + } + except Exception as exc: + print(f"[hdf5_export] depth stream {source_key} " + f"skipped: {exc}") + finally: + if reader is not None: + try: + reader.close() + except Exception: + pass + # 2) 表列:传感器 + 手部骨骼 + 标注(与 LeRobot 列同构,按帧号对齐) columns: dict[str, dict[int, Any]] = {} str_columns: set[str] = set() @@ -406,11 +467,11 @@ def build_hdf5_dataset(dataset_name: str, episode_ids: list[str], output_dir: Pa annotation_defs, ensure_ascii=False) # 6) 标定 → /meta/calibration// attrs(JSON 字符串) - # 与 stereo_triangulate 一致:批次标定缺失/全零 → 回退内置默认标定 - # (否则 meta 里声明的是未生效的标定,3D 数据无法复现)。 - # 回退逻辑统一在 lerobot_export._calibration_docs(单点维护)。 + # 每集一份,所以把 episode_id 一起传进去 —— 批次内各集标定可以不同, + # 不传就只会看到批次级的 calibration/ 目录。 + # 缺失/全零时的默认标定回退统一在 lerobot_export._calibration_docs。 cal_group = meta.create_group(f"calibration/{episode_id}") - cal_docs = _calibration_docs(session_dir) + cal_docs = _calibration_docs(session_dir, episode_id) for name, value in cal_docs.items(): cal_group.attrs[name] = json.dumps(value, ensure_ascii=False) @@ -506,7 +567,16 @@ def _build_readme(dataset_name: str, h5: Any) -> str: img_keys = sorted(n for n in all_names if n.startswith(f"{root}/observation/images/")) if img_keys: - rows.append(f"| **原视频** | {len(img_keys)} 路原始帧 | ✅ 全量 |") + # 不写"全量" —— 视频可能被工作流的 include_video_keys 过滤过,而且 + # 深度流走 depth_sources、不经 camera_streams,本文件**从来不导**。 + # 声称"全量"会让客户以为拿到的是全部视频。 + rows.append(f"| **原视频** | {len(img_keys)} 路原始帧(逐帧 uint8 RGB)" + " | ✅ 已导出,清单见上方结构树 |") + depth_keys = sorted(n for n in all_names + if n.startswith(f"{root}/observation/depth/")) + if depth_keys: + rows.append(f"| **深度图** | {len(depth_keys)} 路,uint16 **毫米**" + " | ✅ 0 = 无效像素(未测到),不是 0 毫米 |") hand_coordinate = next( (coord for coord in ("world", "3d", "2d") if f"{root}/observation/state/hand_left_{coord}" in all_names), @@ -558,6 +628,27 @@ with open("hand_skeleton.mp4", "wb") as f: ``` """ + # 读取示例:只给文件里**确实存在**的路径。原来是左目写死 stereo_left、 + # 手部无数据时仍回落到 hand_right_3d —— 客户照抄都是 KeyError;而且 + # `import h5py` 在 0 列、`with` 却缩进了 4 格,示例本身就不是合法 Python。 + first_img = img_keys[0].split("/")[-1] if img_keys else None + example = ["import h5py", "", 'with h5py.File("dataset.h5", "r") as h5:'] + if first_img: + example.append(f" # 读一帧图像({first_img})") + example.append(f' img = h5["{root}/observation/images/{first_img}"][0]') + if depth_keys: + first_depth = depth_keys[0].split("/")[-1] + example.append(f" # 读一帧深度({first_depth};uint16 毫米,0 = 无效像素)") + example.append(f' depth = h5["{root}/observation/depth/{first_depth}"][0]') + if hand_coordinate: + example.append(" # 读第一帧右手骨骼(字段可能是 world / 3d / 2d)") + example.append(f' hand3d = h5["{root}/observation/state/' + f'hand_right_{hand_coordinate}"][0]') + if f"{root}/observation/annotation" in all_names: + example.append(" # 读全部标注") + example.append(f' labels = h5["{root}/observation/annotation"][:]') + read_example = "\n".join(example) + readme = f"""# {dataset_name} 数据文件说明(HDF5 格式) > 本文件由 EgoData 数据采集系统工作流自动生成 @@ -588,15 +679,7 @@ with open("hand_skeleton.mp4", "wb") as f: ## 四、如何读取 ```python -import h5py - - with h5py.File("dataset.h5", "r") as h5: - # 读一帧左目图像 - img = h5["episode_000000/observation/images/stereo_left"][0] - # 读第一帧右手骨骼(字段可能是 world / 3d / 2d) - hand3d = h5["episode_000000/observation/state/hand_right_{hand_coordinate or '3d'}"][0] - # 读全部标注 - labels = h5["episode_000000/observation/annotation"][:] +{read_example} ``` {video_extract} 其他工具:HDFView、Panoply、MATLAB(h5read)等支持 HDF5 的软件均可打开。 diff --git a/processor/app/i18n.py b/processor/app/i18n.py new file mode 100644 index 0000000000000000000000000000000000000000..056f4a6c5d065f16bcda294aac5335eab86d21d1 --- /dev/null +++ b/processor/app/i18n.py @@ -0,0 +1,89 @@ +"""服务端 i18n —— 让首屏 HTML 直接是正确语言,消掉"先英文后中文"的闪烁。 + +**问题**:语言原本只存在浏览器的 ``localStorage`` 里,服务端不知道。所以渲染出来 +的 ``data-i18n`` 元素只能填**默认语言(英文)**,再由 ``DOMContentLoaded`` 里的 JS +改成中文 —— 改的时机在首屏之后,于是每次整页导航都闪一下。 + +**做法**:语言同时写进 cookie,服务端渲染时就用它取词。首屏直接是对的语言, +不需要任何"先渲染再纠正"。 + +**词典的真源**是 ``web/static/js/i18n-data.js`` —— 一个合法 JSON 载荷外面包一行 +``window.__I18N__ = …``: + + * 浏览器把它当**普通脚本**同步加载(带 ``?v=`` 缓存,不会每页重下) + * 这里剥掉包装后 ``json.loads``,拿到的就是同一份数据 + +两边读同一份文件,所以**不会分叉**。改词条只改那一个文件。 +""" + +from __future__ import annotations + +import json +from contextvars import ContextVar +from pathlib import Path + +from starlette.middleware.base import BaseHTTPMiddleware + +# cookie 名与前端 localStorage 的键保持一致,便于两边对照排查。 +LANG_COOKIE = "lang" +LANGUAGES = ("zh", "en") +# 没选过语言时用哪个。刻意保持 **en** —— 与前端原来的行为一致(切语言前的默认), +# 不借这次改动偷偷改变默认观感。要改成 zh 的话两边一起改(i18n.js 的 currentLang)。 +DEFAULT_LANG = "en" + +_DICT_PATH = (Path(__file__).resolve().parent.parent + / "web" / "static" / "js" / "i18n-data.js") + + +def _load() -> dict[str, dict[str, str]]: + """读 ``i18n-data.js`` 并把 JSON 载荷解析出来。 + + 解析失败**不抛异常**:页面照常起得来,只是文案退回 key 本身 —— 比整个服务 + 因为一个词典文件挂掉好。真要查的话看 ``data`` 是不是空的。 + """ + try: + raw = _DICT_PATH.read_text(encoding="utf-8") + payload = raw[raw.index("{"):raw.rindex("}") + 1] + data = json.loads(payload) + except (OSError, ValueError, IndexError): + return {} + return data if isinstance(data, dict) else {} + + +DATA: dict[str, dict[str, str]] = _load() + + +def normalize(lang: str | None) -> str: + """把任意输入收敛成一个可用语言码。认不出来就回默认。""" + value = str(lang or "").strip().lower() + return value if value in DATA else DEFAULT_LANG + + +_current: ContextVar[str] = ContextVar("egodata_lang", default=DEFAULT_LANG) + + +def get_current_lang() -> str: + return _current.get() + + +def t(key: str, lang: str | None = None) -> str: + """取词。**取不到就返回 key 本身** —— 页面上会显示出一个明显的 + ``nav_foo`` 而不是空白,一眼看得出是漏翻,比静默空着好排查。""" + use = normalize(lang) if lang is not None else _current.get() + table = DATA.get(use) or DATA.get(DEFAULT_LANG) or {} + return table.get(key) or key + + +class LanguageMiddleware(BaseHTTPMiddleware): + """从 cookie 读语言,放进 ContextVar —— Jinja 里注册的 ``t`` 直接用。 + + 用 ContextVar 而不是往每个 ``TemplateResponse`` 的 context 里塞:后者要改 + 20 多处调用点,而 ``t`` 是**每个模板都要用**的东西,漏一个就是一片英文。 + """ + + async def dispatch(self, request, call_next): + token = _current.set(normalize(request.cookies.get(LANG_COOKIE))) + try: + return await call_next(request) + finally: + _current.reset(token) diff --git a/processor/app/lerobot_export.py b/processor/app/lerobot_export.py index 38e39a010c552f16c2b8042a2eda940182a0de20..573893665c962f269ac052c4e09e8f17db435843 100644 --- a/processor/app/lerobot_export.py +++ b/processor/app/lerobot_export.py @@ -1806,6 +1806,82 @@ def _depth_assets(session_dir: Path, frame_count: int, return assets +# 内置默认双目标定 —— 批次标定缺失或整份全零时的兜底。键名取自该文件的 +# ``name`` 字段(head_stereo),与会话里真有一份 head_stereo.json 时算出的键一致。 +_DEFAULT_CALIBRATION_PATH = (Path(__file__).resolve().parent + / "processing" / "modules" + / "calibration_default_head_stereo.json") + + +def _calibration_is_blank(doc: Any) -> bool: + """整份标定里找不出任何非零数值 —— 视为未生效的空白标定。 + + 只递归看数值叶子(标定本质上就是嵌套的数值结构);字符串等非数值字段 + 不构成"有效标定"的证据。所以 ``{"depth_scale": 0.001}`` 不算空白, + 而全零的 intrinsic/rotation/translation 算。 + """ + if isinstance(doc, bool): + return True # bool 是 int 子类,必须先挡掉 + if isinstance(doc, (int, float)): + return doc == 0 + if isinstance(doc, dict): + return all(_calibration_is_blank(v) for v in doc.values()) + if isinstance(doc, (list, tuple)): + return all(_calibration_is_blank(v) for v in doc) + return True + + +def _default_calibration_docs() -> dict[str, Any]: + """读内置默认标定;文件缺失/损坏时返回 {} (调用方自行回落到空)。""" + try: + doc = json.loads(_DEFAULT_CALIBRATION_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(doc, dict): + return {} + return {str(doc.get("name") or _DEFAULT_CALIBRATION_PATH.stem): doc} + + +def _calibration_docs(session_dir: str | Path, + episode_id: str | None = None) -> dict[str, Any]: + """会话标定 → ``{名称: 标定内容}``,供导出侧写进数据集 meta。 + + 目录口径与其余读取点一致(media_groups / depth_hand_3d / lerobot_v21 / + 本文件的深度标定段):先看 ``calibration/``;给了 ``episode_id`` 时优先 + ``meta/calibration//``,再回退 ``meta/calibration/``。键名优先 + 取 JSON 里的 ``name`` 字段,否则用文件名 stem。 + + **批次标定缺失、或整份全零时回退内置默认标定** + (``processing/modules/calibration_default_head_stereo.json``)。不这么做的话, + 导出的 meta 会声明一个从未生效的标定,下游照它复现 3D 全是错的。 + + 只有**每一份**都空白才回退 —— 只要有一份像样的就用会话里的。 + """ + session_dir = Path(session_dir) + calib_dir = session_dir / "calibration" + if episode_id: + namespaced = session_dir / "meta" / "calibration" / str(episode_id) + if namespaced.is_dir(): + calib_dir = namespaced + if not calib_dir.is_dir(): + calib_dir = session_dir / "meta" / "calibration" + + docs: dict[str, Any] = {} + if calib_dir.is_dir(): + for path in sorted(calib_dir.glob("*.json")): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + continue + if not isinstance(value, dict): + continue + docs[str(value.get("name") or path.stem)] = value + + if docs and not all(_calibration_is_blank(v) for v in docs.values()): + return docs + return _default_calibration_docs() or docs + + def _device_metadata(session_dir: Path) -> list[dict]: """Return physical-device declarations for export-side pairing metadata.""" for name in ("metadata.json", "meta/info.json"): diff --git a/processor/app/localstore.py b/processor/app/localstore.py index f1bc5051e23092cf387f0e35a0a6cb8feb653e21..c98bb2ddbcbd7c1f6154b2d0f7259bd499e0fbdd 100644 --- a/processor/app/localstore.py +++ b/processor/app/localstore.py @@ -704,6 +704,36 @@ def write_episode_state(episode_id: str, state: dict) -> None: invalidate_session_cache() +def mutate_episode_state( + episode_id: str, + mutator: Callable[[dict], _MutationResult], +) -> _MutationResult | None: + """Atomically read, mutate, and persist one episode's state. + + ``read_episode_state`` / ``write_episode_state`` each take ``_lock`` on + their own, so a read-modify-write *across* them is not atomic. Two writers + that overlap lose one side's fields — and when the lost field is ``status``, + a batch already pushed back to ``to_review`` can be silently restored to + ``reviewed``. + + That window is not theoretical: the post-run quality tasks (the video gate + and data quality) both decode the same episode's video and therefore finish + within milliseconds of each other, which is exactly when they overlap. + + Mutate the ``state`` dict in place inside ``mutator``; the return value is + passed straight through. Returns ``None`` (and writes nothing) when the + episode has no state — callers must not invent one. + """ + key = str(episode_id) + with _lock: + state = read_episode_state(key) + if not state: + return None + result = mutator(state) + write_episode_state(key, state) + return result + + def set_episode_status(episode_id: str, status: str) -> dict: with _lock: state = read_episode_state(episode_id) diff --git a/processor/app/main.py b/processor/app/main.py index b64a0dbc5398e205ae2dc20f572eb4c1acd70e1f..b96043c9de263c8455dca0d03dbbf4d7fd64dae5 100644 --- a/processor/app/main.py +++ b/processor/app/main.py @@ -12,8 +12,10 @@ from starlette.middleware.gzip import GZipMiddleware from app.config import settings from app.routes import ingestion, video, export, pages, session, annotations, dashboard, devices, auth -from app.api import workflows, worker, projects, exceptions +from app.routes import training +from app.api import workflows, worker, projects, exceptions, quality from app.api import users +from app.i18n import LanguageMiddleware from app.middleware import AuthMiddleware from app.paths import STATIC_DIR from app.version import __version__ @@ -75,7 +77,10 @@ app = FastAPI( app.add_middleware(GZipMiddleware, minimum_size=500) # Auth middleware — protect routes (after gzip, before everything else) +# 语言中间件要在 Auth 之外(先于它)跑:鉴权失败跳登录页时,那个页面本身也要 +# 按 cookie 里的语言渲染。add_middleware 是**后加的先跑**,所以放在 Auth 之后加。 app.add_middleware(AuthMiddleware) +app.add_middleware(LanguageMiddleware) # 媒体/压缩文件下载不走 gzip:zip/mp4/parquet/hdf5 本身已是压缩格式, # 再压一遍只是烧 CPU(zlib level 9 压缩 180MB ≈ 10s),而且流式压缩会 @@ -109,6 +114,15 @@ async def static_cache_headers(request, call_next): response.headers["Cache-Control"] = "public, max-age=31536000, immutable" elif request.url.query: response.headers["Cache-Control"] = "public, max-age=86400" + elif path.startswith("/static/js/"): + # ES module 的相对 import 解析出来的 URL **不带查询串** —— 就算入口 + # script 写了 ?v=,`import './api.js'` 也永远是同一个地址。给它任何 + # 正数的 max-age,升级后都会出现「新入口 + 旧子模块」混用,直接抛 + # TypeError。这类文件必须每次回源校验(未变则 304,开销可忽略)。 + # + # 范围刻意收窄到 /static/js/:带 ?v= 的老脚本走上面那支不受影响; + # /static/ 顶层的第三方库(plyr.js、iconify-*)也不在范围里。 + response.headers["Cache-Control"] = "no-cache" else: response.headers["Cache-Control"] = "public, max-age=3600" return response @@ -139,4 +153,6 @@ app.include_router(workflows.router) app.include_router(worker.router) app.include_router(projects.router) app.include_router(exceptions.router) +app.include_router(quality.router) app.include_router(users.router) +app.include_router(training.router) diff --git a/processor/app/processing/black_glove/hand_landmarker.task b/processor/app/processing/black_glove/hand_landmarker.task new file mode 100644 index 0000000000000000000000000000000000000000..5ecab741879892d97c2f90bbf03bf55d7213db7c --- /dev/null +++ b/processor/app/processing/black_glove/hand_landmarker.task @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1 +size 7819105 diff --git a/processor/app/processing/cleaning/__init__.py b/processor/app/processing/cleaning/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4f9867b6020cfb23132a2c86bc027e990b1ee7e3 --- /dev/null +++ b/processor/app/processing/cleaning/__init__.py @@ -0,0 +1,40 @@ +"""数据清洗 —— 按数据类型检查 episode 质量,异常退回人工审核。 + +对应《以 Episode 为单位的数据清洗方案》(PDF)。核心设计: + +* **按信道声明需求**,不按"这是 UMI 还是 EGO" —— 检查项自己声明需要哪些信道, + 实际有哪些信道由数据决定,两者匹配上才跑(见 ``checks.applicable_checks``)。 +* **四层结果** —— 数据流 / 时间区间 / episode / 训练用途(见 ``report.build_report``)。 +* **只出结论,不产出数据** —— 原始数据一字不改,也不生成 clean_v1 之类的副本。 + 区间信息完整保留在报告里,下游按 ``training_use`` 过滤即可。 +* **可插拔** —— 新增检查项 = 在 ``checks/`` 下放一个 .py(与 processing/modules 同款)。 + +这个包的算法实现与工作流适配器是分开的,与 ``app/processing/black_glove`` 同构: + + app/processing/cleaning/ ← 本包:契约 + 检查项 + 报告组装 + app/processing/modules/data_cleaning.py ← 工作流节点(artifact 契约 + 端口 + 配置) +""" + +from app.processing.cleaning.contract import ( + DEVICE_MODALITIES, CAMERA_MODALITIES, CHANNELS, MODALITY_CHANNELS, + PASS, WARN, FAIL, PENDING, ERROR, + SEVERITY_ORDER, SEVERITY_LABELS, + Finding, Range, StreamResult, + channels_for, normalize_device_modality, worst, is_blocking, +) +from app.processing.cleaning.report import ( + SCHEMA_VERSION, TRAINING_USE_RULES, + build_report, build_timeline, rollup_episode, rollup_training_use, + empty_report, passed_for, +) + +__all__ = [ + "DEVICE_MODALITIES", "CAMERA_MODALITIES", "CHANNELS", "MODALITY_CHANNELS", + "PASS", "WARN", "FAIL", "PENDING", "ERROR", + "SEVERITY_ORDER", "SEVERITY_LABELS", + "Finding", "Range", "StreamResult", + "channels_for", "normalize_device_modality", "worst", "is_blocking", + "SCHEMA_VERSION", "TRAINING_USE_RULES", + "build_report", "build_timeline", "rollup_episode", "rollup_training_use", + "empty_report", "passed_for", +] diff --git a/processor/app/processing/cleaning/checks/__init__.py b/processor/app/processing/cleaning/checks/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..659c8c9247f751031632395fb137b2613ac5f6f1 --- /dev/null +++ b/processor/app/processing/cleaning/checks/__init__.py @@ -0,0 +1,294 @@ +"""清洗检查项注册表 —— 一个文件 = 一个检查项,自动发现。 + +与 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) diff --git a/processor/app/processing/cleaning/checks/cross/__init__.py b/processor/app/processing/cleaning/checks/cross/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8fef4fc41d0e15161e1778cea42b1d7f7d3e1479 --- /dev/null +++ b/processor/app/processing/cleaning/checks/cross/__init__.py @@ -0,0 +1,5 @@ +"""跨模态检查 —— 需要 ≥2 类设备模态同时存在。 + +本目录下一个 .py = 一个检查项(用 @register_check 注册)。 +新增检查项只需在此放文件,注册表会自动发现(见上级 __init__.py 的 walk_packages)。 +""" diff --git a/processor/app/processing/cleaning/checks/depth/__init__.py b/processor/app/processing/cleaning/checks/depth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3fd08de4146280f60e1486a3f51845282b21c368 --- /dev/null +++ b/processor/app/processing/cleaning/checks/depth/__init__.py @@ -0,0 +1,5 @@ +"""深度类检查 —— 是否存在/有效占比。 + +本目录下一个 .py = 一个检查项(用 @register_check 注册)。 +新增检查项只需在此放文件,注册表会自动发现(见上级 __init__.py 的 walk_packages)。 +""" diff --git a/processor/app/processing/cleaning/checks/ego/__init__.py b/processor/app/processing/cleaning/checks/ego/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..78bb8e37a6d7a9bb0e21bbc208238c26488d75e7 --- /dev/null +++ b/processor/app/processing/cleaning/checks/ego/__init__.py @@ -0,0 +1,5 @@ +"""EGO 头戴检查 —— 关键点/左右手/曝光。 + +本目录下一个 .py = 一个检查项(用 @register_check 注册)。 +新增检查项只需在此放文件,注册表会自动发现(见上级 __init__.py 的 walk_packages)。 +""" diff --git a/processor/app/processing/cleaning/checks/glove/__init__.py b/processor/app/processing/cleaning/checks/glove/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b2b8fb9a25d5fd0590090a695eb5d784e1c73528 --- /dev/null +++ b/processor/app/processing/cleaning/checks/glove/__init__.py @@ -0,0 +1,5 @@ +"""触觉手套检查 —— 阵列健康/基线漂移/缺帧。 + +本目录下一个 .py = 一个检查项(用 @register_check 注册)。 +新增检查项只需在此放文件,注册表会自动发现(见上级 __init__.py 的 walk_packages)。 +""" diff --git a/processor/app/processing/cleaning/checks/glove/array_shape.py b/processor/app/processing/cleaning/checks/glove/array_shape.py new file mode 100644 index 0000000000000000000000000000000000000000..2bd547ba27d5c50ec77dcd20615b7a63ae07cd3f --- /dev/null +++ b/processor/app/processing/cleaning/checks/glove/array_shape.py @@ -0,0 +1,82 @@ +"""触觉阵列形状 —— 每帧必须是一整块 16×16。 + +为什么值得单列一项 +------------------ +看着像废话,但它是**静默截断的哨兵**:采集层曾经因为宽度表里没有 ``tactile`` +而落到 3 的兜底值,256 个感应点被悄悄截成 3 个 —— 有列、有值、能加载,检查项 +只看得到 1% 的阵列,而且不报任何错。同一类问题还会再来(换手套、换固件、改列 +宽),所以这里明确把"整块阵列"写成契约。 + +判据 +---- +每帧长度必须等于 ``expected_size``(默认 256 = 16×16)。空元组(``None`` 帧) +也算不合格 —— 它意味着这一帧根本没读到。 + +超出 ``max_bad_ratio`` 判 FAIL,否则 WARN:单帧缺失多半是偶发,系统性截断会是 +接近 100% 的比例。 +""" + +from __future__ import annotations + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, FAIL, +) + + +@register_check +class GloveArrayShapeCheck(CleaningCheck): + slug = "glove.array_shape" + label = "Tactile Array Integrity" + version = "1.0" + description = "Every frame must be a complete 16×16 array (catches silent truncation)" + + requires_channels = ("tactile",) + device_cards = ("glove_sensor",) + default_params = { + "expected_size": 256, # 16×16 + "max_bad_ratio": 0.01, # 不合格帧超过 1% 判 FAIL + } + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + expected = int(self.param(ctx, "expected_size", 256)) + max_ratio = float(self.param(ctx, "max_bad_ratio", 0.01)) + + for stream in ctx.streams_of("tactile"): + values = tuple(stream.series.get("tactile") or ()) + total = len(values) + if not total: + # 列在但一行都没有 —— 与"列不存在"同义,交给别的检查去报 + continue + + bad = [len(item) for item in values if len(item) != expected] + ratio = len(bad) / total + # 出现过的长度 —— 一眼看出是被截断(如 3)还是别的形状 + observed = sorted({len(item) for item in values}) + + if not bad: + findings.append(self.finding( + ctx, PASS, stream=stream.key, + message=f"{stream.key}: {expected}-wide for the whole episode", + metrics={"frames": total, "expected_size": expected}, + )) + continue + + status = FAIL if ratio > max_ratio else WARN + findings.append(self.finding( + ctx, status, stream=stream.key, + message=(f"{stream.key}: {len(bad)}/{total} frames are not " + f"{expected}-wide (observed lengths {observed[:5]})"), + metrics={ + "frames": total, "bad_frames": len(bad), + "bad_ratio": round(ratio, 4), + "expected_size": expected, + "observed_sizes": observed[:10], + "first_bad_frame": next( + (i for i, item in enumerate(values) if len(item) != expected), None), + }, + )) + + return findings diff --git a/processor/app/processing/cleaning/checks/glove/device_status.py b/processor/app/processing/cleaning/checks/glove/device_status.py new file mode 100644 index 0000000000000000000000000000000000000000..39d49aae69161298aa984557c9ab2053846e82d7 --- /dev/null +++ b/processor/app/processing/cleaning/checks/glove/device_status.py @@ -0,0 +1,107 @@ +"""手套连接状态 —— 录制途中掉线。 + +为什么重要 +---------- +手套掉线时阵列读数会**静默保持最后一个值**,看起来完全正常:有列、有 16×16、 +数值也在合理范围。只有状态列说实话。所以这一项是唯一能看见"设备其实没了" +的检查。 + +判定口径(分两种,因为状态词表只见过一个值) +------------------------------------------ +``connected`` 是实测见到的**唯一**取值(S80C / D435 两个项目、全部集、全部帧)。 +其余词表未知,所以: + + 录制中途**状态发生变化** → **FAIL** + 无论变成什么,从"连着"变成别的就是掉线,这个判断不依赖词表。 + + 全程恒定但不是 ``connected`` → **WARN** + 可能是掉线,也可能是我们没见过的正常状态词(如 "ok"/"active")。 + 词表未证实之前不拦批次 —— 拦错了用户会开始无脑点通过,比不拦更糟。 + + 空字符串 → **WARN**(这一帧没读到状态) + +★ 阈值不放进 ``default_params``:词表是字符串,而前端设置面板把所有参数都渲染 + 成数字输入框。硬塞会把面板显示成 NaN。真需要可配时先改面板的类型支持。 +""" + +from __future__ import annotations + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, FAIL, +) + +# 实测唯一见过的取值。其余一律按"词表未知"处理,不直接判失败。 +_HEALTHY = "connected" + + +@register_check +class GloveDeviceStatusCheck(CleaningCheck): + slug = "glove.device_status" + label = "Glove Connection" + version = "1.0" + description = "Device dropped out mid-recording (from status.*_glove)" + + requires_channels = ("device_status",) + device_cards = ("glove_sensor",) + default_params: dict = {} + default_severity = FAIL + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + + for stream in ctx.streams_of("device_status"): + values = tuple(stream.series.get("device_status") or ()) + # 流名就是那一列的最后一段(left_glove / right_glove),用它报位置 + hand = stream.key + if not values: + findings.append(self.finding( + ctx, WARN, stream=hand, + message=f"{hand}: no status column data", + metrics={"frames": 0}, + )) + continue + + blanks = sum(1 for item in values if not item) + distinct: list[str] = [] + for item in values: + if item and item not in distinct: + distinct.append(item) + + # ① 中途变过 —— 与词表无关,见到即掉线 + if len(distinct) > 1: + changes = [i for i in range(1, len(values)) if values[i] != values[i - 1]] + findings.append(self.finding( + ctx, FAIL, stream=hand, + message=(f"{hand}: status changed {len(changes)} times " + f"during recording: {' -> '.join(distinct)}"), + metrics={"frames": len(values), "distinct": distinct, + "first_change_frame": changes[0] if changes else None, + "blank_frames": blanks}, + )) + continue + + status = distinct[0] if distinct else "" + # ② 恒定但词表未知 / ③ 全空 + if not status: + findings.append(self.finding( + ctx, WARN, stream=hand, + message=f"{hand}: status column is entirely blank", + metrics={"frames": len(values), "blank_frames": blanks}, + )) + elif status != _HEALTHY: + findings.append(self.finding( + ctx, WARN, stream=hand, + message=(f"{hand}: status is constantly {status!r} (only " + f"{_HEALTHY!r} has ever been observed; vocabulary " + f"unconfirmed - please verify manually)"), + metrics={"frames": len(values), "status": status}, + )) + else: + findings.append(self.finding( + ctx, PASS, stream=hand, + message=f"{hand}: {_HEALTHY} for the whole episode", + metrics={"frames": len(values), "status": status}, + )) + + return findings diff --git a/processor/app/processing/cleaning/checks/glove/frozen_array.py b/processor/app/processing/cleaning/checks/glove/frozen_array.py new file mode 100644 index 0000000000000000000000000000000000000000..4d7c890df7e852a6f718deca01ed6babed98e057 --- /dev/null +++ b/processor/app/processing/cleaning/checks/glove/frozen_array.py @@ -0,0 +1,132 @@ +"""触觉阵列卡死 —— 设备停止更新,录制端却继续写。 + +为什么重要 +---------- +这是最难发现的一类:**录制链路完全正常**。帧数对得上、时间戳连续、阵列是完整 +的 16×16、数值也在合理区间 —— 只是内容是**很久以前的那一帧**,一直没变。 +事后看数据一切正常,只有对比相邻帧才发现它静止了。 + +判定口径(阈值有实测依据) +-------------------------- +**静止本身不是问题**。实测设备更新率约 7.5Hz,而录制是 30fps,所以同一个值会 +**连续保持 4 帧** —— 这是正常的,不是卡死。 + + S80C 全部集:受压时的保持段长度**只有 4**(`{4}`,无其他取值) + D435 全部集:没有受压保持段 + +(本文件统计的是"保持帧数"= 一个取值连续占了多少帧。同一现象另一种常见记法是 +"相邻帧相等的对数",那个数是 3 —— 4 帧有 3 个间隔。两个数不矛盾,别混。) + +所以阈值有充足余量:``warn_frames=40``(约 1.3 秒)已经远超正常值。取 40 而不是 +"比 4 大一点",是给将来换更新率更低的设备留空间 —— 卡死检测宁可慢一点发现, +也不该把正常的低更新率设备全报一遍。 + +★ 只在**受压时**才判。全零段天然"完全相同"(都是零),把它算进去的话,一集里 + 88% 都是全零帧的批次会报出几百帧的假静止(实测踩过:最长静止段 575 帧,全部 + 来自全零段)。 +""" + +from __future__ import annotations + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, FAIL, +) +from app.processing.cleaning.contract import Range + +# 报告的区间数上限 —— 真卡死时可能几百段,全塞进报告没意义 +_MAX_RANGES = 20 + + +def _static_runs(values: tuple, min_length: int) -> list[tuple[int, int]]: + """受压时完全相同的连续帧段 ``[(起始帧, 长度), ...]``。 + + 只统计**非空且有压力**的帧:全零段天然完全相同,算进来会造出大片假区间。 + """ + runs: list[tuple[int, int]] = [] + total = len(values) + index = 0 + while index < total: + # 向后找同一取值的最长延伸 + end = index + while end + 1 < total and values[end + 1] == values[index]: + end += 1 + length = end - index + 1 + frame = values[index] + pressed = bool(frame) and any(value > 0.0 for value in frame) + if length >= min_length and pressed: + runs.append((index, length)) + index = end + 1 + return runs + + +@register_check +class GloveFrozenArrayCheck(CleaningCheck): + slug = "glove.frozen_array" + label = "Tactile Array Frozen" + version = "1.0" + description = "Array unchanged for a long stretch while pressed (device stopped updating)" + + requires_channels = ("tactile",) + device_cards = ("glove_sensor",) + default_params = { + "warn_frames": 40, # 约 1.3 秒 @30fps;实测正常值 3 帧 + "fail_frames": 300, # 约 10 秒 + } + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + fps = float(ctx.fps or 0.0) or 30.0 + warn_frames = int(self.param(ctx, "warn_frames", 40)) + fail_frames = int(self.param(ctx, "fail_frames", 300)) + + for stream in ctx.streams_of("tactile"): + values = tuple(stream.series.get("tactile") or ()) + if not values: + continue + + runs = _static_runs(values, warn_frames) + if not runs: + findings.append(self.finding( + ctx, PASS, stream=stream.key, + message=f"{stream.key}: no abnormal frozen stretch", + metrics={"frames": len(values), "warn_frames": warn_frames}, + )) + continue + + longest = max(length for _, length in runs) + status = FAIL if longest >= fail_frames else WARN + ranges = tuple( + Range( + start_sec=start / fps, + end_sec=(start + length - 1) / fps, + code=self.slug, + severity=FAIL if length >= fail_frames else WARN, + stream=stream.key, + channel="tactile", + start_frame=start, + end_frame=start + length - 1, + rule=self.slug, + message=f"array unchanged for {length} frames", + evidence={"frozen_frames": length}, + ) + for start, length in runs[:_MAX_RANGES] + ) + + findings.append(self.finding( + ctx, status, stream=stream.key, + message=(f"{stream.key}: {len(runs)} frozen stretches while " + f"pressed (longest {longest} frames ~= {longest / fps:.1f}s)"), + metrics={ + "frames": len(values), + "static_runs": len(runs), + "longest_run_frames": longest, + "longest_run_sec": round(longest / fps, 3), + "warn_frames": warn_frames, + "fail_frames": fail_frames, + }, + ranges=ranges, + )) + + return findings diff --git a/processor/app/processing/cleaning/checks/glove/gate_floor.py b/processor/app/processing/cleaning/checks/glove/gate_floor.py new file mode 100644 index 0000000000000000000000000000000000000000..ec5cb514e04cf0a3afff6546e943d08081e1246f --- /dev/null +++ b/processor/app/processing/cleaning/checks/glove/gate_floor.py @@ -0,0 +1,87 @@ +"""触觉阵列取值下限 —— 检测**编码/量纲漂移**。 + +为什么需要它 +------------ +实测这份手套数据有个硬门限:取值**要么是 0,要么 ≥ 500.02**,0 到 500 之间 +一个值都没有(从 0 直接跳到 508.09,没有过渡)。所以 + + ``0`` 的含义是"未接触",**不是"力为零"** + +这个门限是固件行为,也是所有下游解读的前提。它一旦变了,**别的一切看起来都还 +正常**:列还在、还是 16×16、数还在合理区间 —— 但含义已经不同了。典型触发: + + * 固件改版,直接输出原始 ADC(0–4095)而不是门限后的值 + * 标定系数 ``scale`` 被应用了两次(或该应用而没应用) + * 换了另一款手套 + +判据 +---- +非零值里低于 ``min_contact_value``(默认 500)的**占比**。 +超出 ``fail_ratio`` 判 FAIL,否则 WARN。实测两个项目全部集都是 0 个越界值, +所以正常数据有充足余量。 +""" + +from __future__ import annotations + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, FAIL, +) + + +@register_check +class GloveGateFloorCheck(CleaningCheck): + slug = "glove.gate_floor" + label = "Contact Threshold" + version = "1.0" + description = "Non-zero values must exceed the contact floor (catches silent encoding changes)" + + requires_channels = ("tactile",) + device_cards = ("glove_sensor",) + default_params = { + # 实测最小非零值 500.02。留一点余量给固件微调,但远低于它就没有意义了 + # —— 那正是"编码变了"的样子。 + "min_contact_value": 500.0, + "fail_ratio": 0.05, # 非零值里超过 5% 低于门限 → FAIL + } + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + floor = float(self.param(ctx, "min_contact_value", 500.0)) + fail_ratio = float(self.param(ctx, "fail_ratio", 0.05)) + + for stream in ctx.streams_of("tactile"): + values = tuple(stream.series.get("tactile") or ()) + flat = [float(v) for item in values if item for v in item] + nonzero = [v for v in flat if v > 0.0] + if not nonzero: + # 整集没有非零值 —— 那是 glove.no_contact 的事,这里不重复报 + continue + + below = [v for v in nonzero if v < floor] + ratio = len(below) / len(nonzero) + + if not below: + findings.append(self.finding( + ctx, PASS, stream=stream.key, + message=f"{stream.key}: all non-zero values above the floor", + metrics={"nonzero": len(nonzero), "min_value": round(min(nonzero), 3)}, + )) + continue + + status = FAIL if ratio > fail_ratio else WARN + findings.append(self.finding( + ctx, status, stream=stream.key, + message=(f"{stream.key}: {len(below)}/{len(nonzero)} non-zero values" + f" are below the contact floor {floor:g} (min {min(below):.3f})" + f" - the array encoding may have changed"), + metrics={ + "nonzero": len(nonzero), "below_floor": len(below), + "below_ratio": round(ratio, 4), + "min_contact_value": floor, + "min_value": round(min(nonzero), 3), + }, + )) + + return findings diff --git a/processor/app/processing/cleaning/checks/glove/no_contact.py b/processor/app/processing/cleaning/checks/glove/no_contact.py new file mode 100644 index 0000000000000000000000000000000000000000..62d5cd972e024a2dda4086ced334fabf50b38eeb --- /dev/null +++ b/processor/app/processing/cleaning/checks/glove/no_contact.py @@ -0,0 +1,70 @@ +"""整集无接触 —— 手套没戴上、没开机,或阵列整体失效。 + +为什么单列一项 +-------------- +它和"某一根手指没碰"完全不同量级:后者是正常的(实测单集里 30–86% 的感应点 +全程为 0,因为只压到阵列的一部分)。**整只手套一帧都没有值**才是设备层面的问题。 + +判据保守是故意的 +---------------- +实测最低的一集也有 **11.4%** 的帧存在压力,所以"全零"离正常值有极大余量, +不需要为它编阈值。``min_active_ratio`` 默认 0.0 = 只在**整集恒为 0** 时才报, +用户想更严可以往上调。 + +只报 WARN:手套没戴上也可能是**操作员故意的**(这一段就是不给触觉), +没到判失败的程度。 +""" + +from __future__ import annotations + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, +) + + +@register_check +class GloveNoContactCheck(CleaningCheck): + slug = "glove.no_contact" + label = "No Contact" + version = "1.0" + description = "No pressure readings at all in this episode (not worn / not working)" + + requires_channels = ("tactile",) + device_cards = ("glove_sensor",) + default_params = { + # 0.0 = 只在整集恒为 0 时报。实测最低 11.4%,余量充足。 + "min_active_ratio": 0.0, + } + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + min_ratio = float(self.param(ctx, "min_active_ratio", 0.0)) + + for stream in ctx.streams_of("tactile"): + values = tuple(stream.series.get("tactile") or ()) + if not values: + continue + + active = sum(1 for frame in values + if frame and any(value > 0.0 for value in frame)) + ratio = active / len(values) + + if ratio > min_ratio: + findings.append(self.finding( + ctx, PASS, stream=stream.key, + message=f"{stream.key}: has contact frames", + metrics={"frames": len(values), "active_frames": active, + "active_ratio": round(ratio, 4)}, + )) + else: + findings.append(self.finding( + ctx, WARN, stream=stream.key, + message=(f"{stream.key}: no pressure readings in any of the " + f"{len(values)} frames (not worn, or the array failed)"), + metrics={"frames": len(values), "active_frames": active, + "active_ratio": round(ratio, 4)}, + )) + + return findings diff --git a/processor/app/processing/cleaning/checks/umi/__init__.py b/processor/app/processing/cleaning/checks/umi/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..29101c679b7b0589f958e307df0f99afb15a7871 --- /dev/null +++ b/processor/app/processing/cleaning/checks/umi/__init__.py @@ -0,0 +1,5 @@ +"""UMI 夹爪检查 —— SLAM 连续性/帧同步/夹爪量程。 + +本目录下一个 .py = 一个检查项(用 @register_check 注册)。 +新增检查项只需在此放文件,注册表会自动发现(见上级 __init__.py 的 walk_packages)。 +""" diff --git a/processor/app/processing/cleaning/checks/umi/action_present.py b/processor/app/processing/cleaning/checks/umi/action_present.py new file mode 100644 index 0000000000000000000000000000000000000000..04fac07cc1a4a9bccd60f779f6b18e21ee39f98d --- /dev/null +++ b/processor/app/processing/cleaning/checks/umi/action_present.py @@ -0,0 +1,118 @@ +"""action 有效性 —— 监督信号是否真的存在、是否可用。 + +为什么重要 +---------- +**action 是 ACT 的监督信号**(``L1(预测, action)``)。没有它就算不出 loss。 +而它是**派生**出来的,不是采出来的,所以有四种失效方式: + + ① 工作流没跑 / 跑失败 → action 还是采集端的**全零占位** + ★ 最阴险:有列、有 7 维、能加载,全是 0 —— 不报任何错 + ② 整集派生失败(位姿样本不足)→ 列根本不写 → 导出时缺失 + ★ 训练直接 KeyError(2026-09-11 踩过) + ③ 部分帧派生失败 → 那些帧的 action 是 null + ④ 派生成功但数值离群 → 归一化统计量被撑大(交给 umi.slam_continuity 解释) + +严重度 +------ +① ② 判 **FAIL** —— 没有监督信号,这一集对训练毫无价值。 +③ 判 **WARN** —— 部分帧缺失,剩余帧仍可训练。 + +关于"全零" +---------- +采集端在 UMI 硬件上写的就是全零(硬件不录 action),所以**必须区分** +"还没派生"和"派生了但动作真的是零"。判据是**整列方差**: +派生的 action 即使动作很小,帧间也会有变化;全零占位则方差严格为 0。 +""" + +from __future__ import annotations + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, FAIL, +) + + +@register_check +class UmiActionPresentCheck(CleaningCheck): + slug = "umi.action_present" + label = "Action Validity" + version = "1.0" + description = "Missing supervision signal, all-zero placeholder, or frames never derived" + + requires_channels = ("action",) + + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + + device_cards = ("gripper_device",) + + default_params = { + "min_coverage": 0.95, # 有 action 的帧占比下限 + "zero_epsilon": 1e-9, # 判定"全零"的容差 + } + default_severity = FAIL + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + min_coverage = float(self.param(ctx, "min_coverage", 0.95)) + epsilon = float(self.param(ctx, "zero_epsilon", 1e-9)) + + for stream in ctx.streams_of("action"): + rows = int(stream.rows or 0) + if not rows: + # 列存在但一行都没有 —— 与"列不存在"同义 + findings.append(self.finding( + ctx, FAIL, stream=stream.key, + message="the action column has no data at all (derivation failed for the whole episode)", + metrics={"rows": 0}, + )) + continue + + values = stream.series.get("action") or () + usable = [item for item in values if item] + coverage = len(usable) / rows if rows else 0.0 + + # 整列方差 —— 区分"全零占位"和"动作真的很小" + flat: list[float] = [] + for item in usable[:5000]: # 抽样即可,全零占位必然处处为零 + try: + flat.extend(float(value) for value in item) + except (TypeError, ValueError): + continue + spread = (max(flat) - min(flat)) if flat else 0.0 + all_zero = spread <= epsilon + + metrics = { + "rows": rows, + "usable": len(usable), + "coverage": round(coverage, 4), + "value_spread": spread, + } + + if all_zero and usable: + findings.append(self.finding( + ctx, FAIL, stream=stream.key, + message="action is all zeros - likely still the recorder placeholder (workflow never ran, or derivation failed)", + metrics=metrics, + )) + continue + + if not usable: + findings.append(self.finding( + ctx, FAIL, stream=stream.key, + message="the action column exists but every value is empty (derivation produced no frames)", + metrics=metrics, + )) + continue + + if coverage < min_coverage: + findings.append(self.finding( + ctx, WARN, stream=stream.key, + message=f"only {len(usable)}/{rows} frames have action" + f"({coverage:.1%}),其余帧无监督信号", + metrics=metrics, + )) + continue + + findings.append(self.finding(ctx, PASS, stream=stream.key, + metrics=metrics)) + return findings diff --git a/processor/app/processing/cleaning/checks/umi/av_sync.py b/processor/app/processing/cleaning/checks/umi/av_sync.py new file mode 100644 index 0000000000000000000000000000000000000000..4c0884d30458c589e6d8ffa60b4d9bd1ba720843 --- /dev/null +++ b/processor/app/processing/cleaning/checks/umi/av_sync.py @@ -0,0 +1,84 @@ +"""视频 ↔ 数据 帧同步 —— 各数据流的帧数与 RGB 视频是否一致。 + +为什么重要 +---------- +UMI 的 action 按帧号对齐:第 i 行动作对应第 i 帧画面。任何一路数据与视频 +帧数不一致,都会让"看这一帧、预测下一步"的监督信号整体错位。 + +与 ``video.frame_drop`` 的分工 +----------------------------- +``video.frame_drop`` 比的是 **视频帧数 vs parquet 总行数**(整体量)。 +本项比的是 **每一路数据流各自的帧数**(结构性)—— +UMI 特有:Slam / gripper_state / force 是三条独立写入的列, +采集端丢帧时可能只有其中一路短了,总数却对得上。 + +这也是用户提的"整个的数据有没有根据 RGB 视频进行帧同步"。 +""" + +from __future__ import annotations + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, FAIL, +) + + +@register_check +class UmiAvSyncCheck(CleaningCheck): + slug = "umi.av_sync" + label = "Video ↔ Data Frame Sync" + version = "1.0" + description = "Frame counts differ between data streams" + + # 需要视频和至少一路数据同时存在才有意义。 + # ★ 不加 cross_modal —— 它比的是【同一个 UMI 设备内部】的视频与各数据列, + # 不是两张设备卡片之间。requires_channels 已经把"必须有 rgb"表达清楚了, + # 再加 cross_modal 会把它错误地挤进"跨设备" tab。 + requires_channels = ("rgb", "time") + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + device_cards = ("gripper_device",) + + default_params = {"tolerance": 2} + default_severity = FAIL + + def run(self, ctx: CheckContext) -> list[Finding]: + # 视频侧:取第一路 RGB 的帧数作为基准 + rgb_streams = ctx.streams_of("rgb") + if not rgb_streams: + return [] + reference = None + reference_key = "" + for stream in rgb_streams: + frames = int((stream.video or {}).get("frame_count") or stream.rows or 0) + if frames: + reference, reference_key = frames, stream.key + break + if not reference: + return [] + + tolerance = int(self.param(ctx, "tolerance", 2)) + drifted: dict[str, int] = {} + for stream in ctx.streams: + if stream.channel in ("rgb", "depth") or not stream.rows: + continue + if stream.key == reference_key: + continue + diff = abs(int(stream.rows) - reference) + if diff > tolerance: + drifted[stream.key] = int(stream.rows) + + metrics = {"reference_stream": reference_key, + "reference_frames": reference, + "tolerance": tolerance, + "drifted": drifted} + + if not drifted: + return [self.finding(ctx, PASS, metrics=metrics)] + + detail = ",".join(f"{key}={rows}" for key, rows in sorted(drifted.items())) + return [self.finding( + ctx, + FAIL, + message=f"these streams do not match the video frame count ({reference}): {detail}", + metrics=metrics, + )] diff --git a/processor/app/processing/cleaning/checks/umi/gripper_range.py b/processor/app/processing/cleaning/checks/umi/gripper_range.py new file mode 100644 index 0000000000000000000000000000000000000000..0b92af3b67923d2ce8f12eff123c689aa1850491 --- /dev/null +++ b/processor/app/processing/cleaning/checks/umi/gripper_range.py @@ -0,0 +1,157 @@ +"""夹爪量程与力基线 —— 开合度越界、力读数漂移。 + +为什么重要 +---------- +**开合度越界**:``umi_slam_action._gripper_fraction()`` 会把开合度 +``np.clip(0, 1)``。也就是说越界值在派生 action 时被**静默夹掉**了 —— +标定异常的集看起来"正常",但动作里的夹爪维度是失真的。本项就是在 +clip 之前把它抓出来(原始列 ``observation.gripper_state[0]`` 是 0–100)。 + +**力基线漂移**:夹爪没接触物体时力读数应在零附近。若整集的静息基线缓慢 +上移,说明传感器漂移/温漂,用力做阈值判断的模型会失真。 + +判定口径 +-------- +开合度越界默认判 **WARN**(数据还能用,但要知道标定有问题); +力漂移默认判 **WARN**(同上)。两者都不拦数据,只报告。 +""" + +from __future__ import annotations + +import math + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, Range, PASS, WARN, +) + +# 原始列里开合度的量程(采集端契约:[open_pct, gripped, fz_mn]) +OPEN_PCT_MIN = 0.0 +OPEN_PCT_MAX = 100.0 + + +def _median(values: list[float]) -> float: + if not values: + return 0.0 + ordered = sorted(values) + middle = len(ordered) // 2 + if len(ordered) % 2: + return ordered[middle] + return (ordered[middle - 1] + ordered[middle]) / 2.0 + + +@register_check +class UmiGripperRangeCheck(CleaningCheck): + slug = "umi.gripper_range" + label = "Gripper Range / Force Baseline" + version = "1.0" + description = "Gripper opening out of range, or force baseline drift" + + requires_channels = ("gripper_state",) + + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + + device_cards = ("gripper_device",) + + default_params = { + "max_out_of_range_ratio": 0.01, # 越界帧占比上限 + "max_force_drift": 200.0, # 前后半段基线差(mN) + } + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + max_ratio = float(self.param(ctx, "max_out_of_range_ratio", 0.01)) + max_drift = float(self.param(ctx, "max_force_drift", 200.0)) + + for stream in ctx.streams_of("gripper_state"): + samples = stream.series.get("gripper_state") or () + if not samples: + continue + fps = float(stream.fps or ctx.fps or 30.0) + + out_of_range: list[Range] = [] + values: list[float] = [] + for index, sample in enumerate(samples): + if not sample: + continue + try: + pct = float(sample[0]) + except (TypeError, ValueError, IndexError): + continue + values.append(pct) + if OPEN_PCT_MIN <= pct <= OPEN_PCT_MAX: + continue + out_of_range.append(Range( + start_sec=index / fps, end_sec=(index + 1) / fps, + code=self.slug, severity=WARN, + stream=stream.key, channel="gripper_state", + start_frame=index, end_frame=index, + rule=self.slug, + message=f"opening {pct:.1f} is outside the 0-100 range" + f"(派生 action 时会被静默夹掉)", + evidence={"open_pct": round(pct, 2)}, + )) + + if not values: + continue + + ratio = len(out_of_range) / len(values) + metrics: dict = { + "samples": len(values), + "out_of_range": len(out_of_range), + "out_of_range_ratio": round(ratio, 4), + "min": round(min(values), 2), + "max": round(max(values), 2), + } + + if ratio > max_ratio: + findings.append(self.finding( + ctx, WARN, stream=stream.key, + message=f"{len(out_of_range)}/{len(values)} frames out of range" + f"({ratio:.1%}),标定可能异常", + metrics=metrics, + ranges=out_of_range[:50], + )) + else: + findings.append(self.finding(ctx, PASS, stream=stream.key, + metrics=metrics)) + + # 力基线漂移 —— 独立的信道,单独一轮 + for stream in ctx.streams_of("force"): + forces = stream.series.get("force") or () + if len(forces) < 20: + continue + magnitudes = [] + for sample in forces: + if not sample: + continue + try: + magnitudes.append(_norm3(sample)) + except (TypeError, ValueError): + continue + if len(magnitudes) < 20: + continue + + half = len(magnitudes) // 2 + first, second = _median(magnitudes[:half]), _median(magnitudes[half:]) + drift = abs(second - first) + metrics = {"baseline_first_half": round(first, 2), + "baseline_second_half": round(second, 2), + "drift_mN": round(drift, 2)} + + if drift <= max_drift: + findings.append(self.finding(ctx, PASS, stream=stream.key, + metrics=metrics)) + continue + findings.append(self.finding( + ctx, WARN, stream=stream.key, + message=f"force baseline drift {drift:.0f} mN" + f"(前半 {first:.0f} → 后半 {second:.0f}),疑似传感器漂移", + metrics=metrics, + )) + return findings + + +def _norm3(sample) -> float: + return math.sqrt(sum(float(value) ** 2 for value in sample[:3])) diff --git a/processor/app/processing/cleaning/checks/umi/slam_continuity.py b/processor/app/processing/cleaning/checks/umi/slam_continuity.py new file mode 100644 index 0000000000000000000000000000000000000000..8a98cc92b2ee36f507d810fb146291fd0f943739 --- /dev/null +++ b/processor/app/processing/cleaning/checks/umi/slam_continuity.py @@ -0,0 +1,158 @@ +"""SLAM 轨迹连续性 —— 相邻位姿之间出现不可能的速度/角速度跳变。 + +为什么重要 +---------- +UMI 的 action 是从 SLAM 位姿**差分**出来的(见 ``app/umi_slam_action.py``)。 +位姿一旦跳变,差分出来的动作就是一个巨大的假位移;模型会学到"突然瞬移"。 +官方也承认 ORB_SLAM3 是整条流水线"最脆弱的部分"。 + +常见来源:视觉跟踪丢失后重定位、弱纹理/暗光、快速转手腕导致运动模糊。 + +判定分两档(这正是本项的设计,不是"只报告不判定") +-------------------------------------------------- +方案明确警告:「SLAM 单帧跳了 113.9 毫米可以作为问题线索,但不能马上定成 +全平台红线」。113.9mm 来自本平台实测(`umi_slam_action.py` 的 docstring 记录了 +插值方案会横跨重定位跳变),它是**观测线索**不是阈值来源。 + +所以**单次跳变只判 WARN** —— 一次重定位不该废掉一整集(实测 +UMIGripper_Action_AI_000001 就有一次真实的 6.8 m/s 跳变,属"该警告、不该拦")。 + +但跳变**达到 ``fail_jumps``(默认 10 处)判 FAIL**:那已经不是偶发重定位,而是 +SLAM 全程没跟住,差分出来的动作整段都是假的 —— 这种数据确实不能训,按"数据不对 +就拦回人工审核"处理。 + +★ 本文件的旧注释曾写成"默认判 WARN 而非 FAIL……只报告",与实现不符(实现一直 + 是分档的)。以本段为准。阈值本身仍须用人工确认过的好/坏数据校准后才固化。 +""" + +from __future__ import annotations + +import math + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, Range, PASS, WARN, FAIL, +) +from app.processing.cleaning.contract import pose_is_valid + + +def _norm3(vector) -> float: + return math.sqrt(sum(float(value) ** 2 for value in vector[:3])) + + +@register_check +class UmiSlamContinuityCheck(CleaningCheck): + slug = "umi.slam_continuity" + label = "SLAM Trajectory Continuity" + version = "1.0" + description = "Impossible linear or angular velocity between adjacent poses" + + requires_channels = ("slam",) + + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + + device_cards = ("gripper_device",) + + # 单位:米/秒、弧度/秒。人手快速挥动约 2 m/s、6 rad/s 量级, + # 这里给的是"明显超出人类可达"的起点值。 + # + # 判级用两个计数阈值,而不是"速度超过多少倍"—— 后者在默认值下会退化成 + # "任何一次跳变都判失败"(一次跳变就废掉一整集太重了)。实测 episode 1 + # 帧 293 有一次 6.8 m/s 的真实重定位跳变,属"该警告、但不该直接拦"。 + default_params = { + "max_linear_mps": 5.0, + "max_angular_rps": 20.0, + "warn_jumps": 1, # 达到此数即警告 + "fail_jumps": 10, # 达到此数判失败(说明 SLAM 全程不稳) + } + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + max_linear = float(self.param(ctx, "max_linear_mps", 5.0)) + max_angular = float(self.param(ctx, "max_angular_rps", 20.0)) + fail_jumps = int(self.param(ctx, "fail_jumps", 10)) + + for stream in ctx.streams_of("slam"): + poses = stream.series.get("pose") or () + stamps = stream.series.get("t") or () + if len(poses) < 2: + continue + fps = float(stream.fps or ctx.fps or 30.0) + dt_default = 1.0 / fps if fps > 0 else 1.0 / 30.0 + + jumps: list[Range] = [] + peak_linear = 0.0 + peak_angular = 0.0 + + for index in range(1, len(poses)): + prev, cur = poses[index - 1], poses[index] + # ★ 必须用 pose_is_valid,不能只判长度。 + # + # 全零占位位姿长度也是 7,能过 len 检查,但四元数模为 0 —— + # 两个零四元数点积为 0,acos(0)=π/2 ⇒ 夹角 180° ⇒ 每帧 + # 94.25 rad/s。实测 Test94 的 slam_pose 有 437/599 行是全零, + # 于是 599 帧报出 465 处"跳变",整集被假判 FAIL 并拦住批次。 + if not pose_is_valid(prev) or not pose_is_valid(cur): + continue + try: + delta = [float(cur[i]) - float(prev[i]) for i in range(3)] + except (TypeError, ValueError): + continue + + # 时间间隔取真实时间戳;缺失时回落到 fps + dt = dt_default + if len(stamps) > index and stamps[index] and stamps[index - 1]: + try: + span = float(stamps[index]) - float(stamps[index - 1]) + if 0 < span < 1.0: # 超过 1 秒说明中间丢了整段 + dt = span + except (TypeError, ValueError): + pass + + linear = _norm3(delta) / dt + # 姿态差:四元数点积 → 夹角。|dot| 接近 1 表示几乎没转。 + dot = sum(float(prev[3 + i]) * float(cur[3 + i]) for i in range(4)) + angle = 2.0 * math.acos(max(-1.0, min(1.0, abs(dot)))) + angular = angle / dt + + peak_linear = max(peak_linear, linear) + peak_angular = max(peak_angular, angular) + + if linear <= max_linear and angular <= max_angular: + continue + jumps.append(Range( + start_sec=index / fps, end_sec=(index + 1) / fps, + code=self.slug, severity=WARN, + stream=stream.key, channel="slam", + start_frame=index - 1, end_frame=index, + rule=self.slug, + message=f"pose jump at frame {index}: " + f"{_norm3(delta) * 1000:.1f}mm / {math.degrees(angle):.1f}°", + evidence={"linear_mps": round(linear, 3), + "angular_rps": round(angular, 3)}, + )) + + metrics = { + "frames": len(poses), + "jumps": len(jumps), + "peak_linear_mps": round(peak_linear, 3), + "peak_angular_rps": round(peak_angular, 3), + } + if not jumps: + findings.append(self.finding(ctx, PASS, stream=stream.key, + metrics=metrics)) + continue + + findings.append(self.finding( + ctx, + FAIL if len(jumps) >= fail_jumps else WARN, + stream=stream.key, + message=f"{len(jumps)} pose jumps, peak " + f"{peak_linear:.1f} m/s / " + f"{math.degrees(peak_angular):.0f} deg/s " + f"(verify against the video)", + metrics=metrics, + ranges=jumps[:50], # 报告里最多带 50 个,避免 JSON 过大 + )) + return findings diff --git a/processor/app/processing/cleaning/checks/umi/slam_coverage.py b/processor/app/processing/cleaning/checks/umi/slam_coverage.py new file mode 100644 index 0000000000000000000000000000000000000000..496cf50ba3718c74bfa2a1c57b27a0f42bb3504a --- /dev/null +++ b/processor/app/processing/cleaning/checks/umi/slam_coverage.py @@ -0,0 +1,105 @@ +"""SLAM 位姿覆盖率 —— 多少帧的位姿是**插值补出来的**。 + +★ 别把"自己格子为空"当成问题 +-------------------------------- +采集端的位姿流是 20/20/60ms **突发**,而数据帧是 1/30s 均匀网格。某个 +33ms 窗口里一个位姿都没到,这一格的缓冲就是空的 —— **这是设计使然**。 + +实测(52 集训练集):**28.2% 的帧自己格子为空**(p90 32.8%)。 +第一版检查项拿这个当指标,结果 52/52 集全部报警 —— 典型的阈值照搬。 + +真正该关心的是:既没有本地样本、**相邻格子也没有**,只能靠插值硬补的帧。 +``poses_from_trajectory`` 汇总所有格子按时间取最近样本,实测能救回绝大多数 +空帧,只有剩下的才插值。 + +实测(同上,4 集抽样): + 集 0: 插值 2/326 = 0.6% + 集 1: 插值 17/313 = 5.4% ← ★ 明显高于其他集 + 集 2: 插值 4/384 = 1.0% + 集 7: 插值 0/308 = 0.0% + +★ 集 1 正是 ``umi.slam_continuity`` 报出 6.8 m/s 跳变的那一集 —— +两个独立指标指向同一件事:那一集 SLAM 跟丢过。 +所以这两项要**一起看**:插值率高 + 有跳变 = 基本可以确定是跟踪丢失。 + +判定口径 +-------- +默认 **WARN**:插值帧本身不代表数据不可用(插值的是平滑轨迹), +它是"这段 SLAM 不稳"的信号。 +""" + +from __future__ import annotations + +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, FAIL, +) + + +@register_check +class UmiSlamCoverageCheck(CleaningCheck): + slug = "umi.slam_coverage" + label = "SLAM Pose Coverage" + version = "2.0" + description = "Poses only recoverable by interpolation (no real sample in nearby slots)" + + requires_channels = ("slam",) + + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + + device_cards = ("gripper_device",) + + # 实测正常集 0~1%,异常集 5.4%。起点值取 2% / 10%: + # 低于 2% 属正常抖动,超过 10% 说明这一段基本没有真实位姿。 + # ★ 这两个数是拿 52 集训练集跑出来的,不是拍脑袋 —— 但样本只有 52 集, + # 上生产前应拿更多数据复核。 + default_params = { + "warn_ratio": 0.02, + "fail_ratio": 0.10, + } + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + warn_ratio = float(self.param(ctx, "warn_ratio", 0.02)) + fail_ratio = float(self.param(ctx, "fail_ratio", 0.10)) + + for stream in ctx.streams_of("slam"): + # evidence 提供两种粒度: + # "interpolated" —— 逐帧布尔(本检查项用这个,能出区间) + # "coverage" —— 直接给汇总数字({"frames","direct","interpolated"}) + flags = stream.series.get("interpolated") + summary = stream.series.get("coverage") or {} + if flags: + total = len(flags) + count = sum(1 for item in flags if item) + elif summary: + total = int(summary.get("frames") or 0) + count = int(summary.get("interpolated") or 0) + else: + continue + if not total: + continue + ratio = count / total + + metrics = { + "frames": total, + "interpolated_frames": count, + "interpolated_ratio": round(ratio, 4), + } + + if ratio < warn_ratio: + findings.append(self.finding(ctx, PASS, stream=stream.key, + metrics=metrics)) + continue + findings.append(self.finding( + ctx, + FAIL if ratio >= fail_ratio else WARN, + stream=stream.key, + message=f"{count}/{total} poses ({ratio:.1%}) are interpolation-" + f"only - no real sample in the local or neighbouring slots," + f" which suggests SLAM lost tracking (review together with" + f" SLAM Trajectory Continuity)", + metrics=metrics, + )) + return findings diff --git a/processor/app/processing/cleaning/checks/video/__init__.py b/processor/app/processing/cleaning/checks/video/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8718f4ac343e8e67615ede5276b177ae39408d9a --- /dev/null +++ b/processor/app/processing/cleaning/checks/video/__init__.py @@ -0,0 +1,5 @@ +"""视频类检查 —— 解码/黑屏/冻结/丢帧。 + +本目录下一个 .py = 一个检查项(用 @register_check 注册)。 +新增检查项只需在此放文件,注册表会自动发现(见上级 __init__.py 的 walk_packages)。 +""" diff --git a/processor/app/processing/cleaning/checks/video/black_screen.py b/processor/app/processing/cleaning/checks/video/black_screen.py new file mode 100644 index 0000000000000000000000000000000000000000..85cacce7e9a65871e8148fbb8b4c9afdca9863d1 --- /dev/null +++ b/processor/app/processing/cleaning/checks/video/black_screen.py @@ -0,0 +1,86 @@ +"""黑屏 —— 镜头被挡、补光灯没开、或采集程序写了空帧。 + +为什么重要 +---------- +黑屏段里的视觉信息为零。模型在这些帧上学到的是"画面全黑时该做什么", +通常是噪声;若黑屏占比高,还会把图像归一化统计量带偏。 + +判定口径 +---------- +检测本身由 ``app/video_quality.py`` 完成(按灰度均值 + 暗像素占比双阈值)。 +本检查项只负责把它的区间翻成统一的 Finding/Range 并套用节点配置的阈值 —— +**不重复实现检测算法**,避免两处口径漂移。 +""" + +from __future__ import annotations + +from app.processing.cleaning.contract import VIDEO_CAPABLE_MODALITIES +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, Range, PASS, WARN, FAIL, +) + + +@register_check +class VideoBlackScreenCheck(CleaningCheck): + slug = "video.black_screen" + label = "Black Screen" + version = "1.0" + description = "Frame is fully or nearly black" + + requires_channels = ("rgb",) + + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + + device_cards = VIDEO_CAPABLE_MODALITIES + + default_params = {"min_sec": 0.5, "fail_ratio": 0.05} + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + for stream in ctx.streams_of("rgb"): + report = stream.video or {} + ranges = report.get("black_ranges") or [] + if not ranges: + continue + + fps = float(report.get("fps") or stream.fps or ctx.fps or 30.0) + min_sec = float(self.param(ctx, "min_sec", 0.5)) + converted: list[Range] = [] + total_sec = 0.0 + for item in ranges: + try: + start, end = float(item[0]), float(item[1]) + except (TypeError, ValueError, IndexError): + continue + if end - start < min_sec: + continue + total_sec += end - start + converted.append(Range( + start_sec=start, end_sec=end, + code=self.slug, severity=WARN, + stream=stream.key, channel="rgb", + start_frame=int(start * fps), end_frame=int(end * fps), + rule=self.slug, + message=f"black screen for {end - start:.1f}s", + )) + + if not converted: + continue + + duration = float(ctx.duration_sec or 0.0) + ratio = (total_sec / duration) if duration > 0 else 0.0 + fail_ratio = float(self.param(ctx, "fail_ratio", 0.05)) + status = FAIL if ratio >= fail_ratio else WARN + + findings.append(self.finding( + ctx, status, stream=stream.key, + message=f"{len(converted)} black-screen stretches, {total_sec:.1f}s total" + + (f"(占 {ratio:.1%})" if duration else ""), + metrics={"segments": len(converted), + "total_sec": round(total_sec, 3), + "ratio": round(ratio, 4)}, + ranges=converted, + )) + return findings diff --git a/processor/app/processing/cleaning/checks/video/decode_error.py b/processor/app/processing/cleaning/checks/video/decode_error.py new file mode 100644 index 0000000000000000000000000000000000000000..5a7ea67353c0af8cd2531792f0f791cb1db2a1c1 --- /dev/null +++ b/processor/app/processing/cleaning/checks/video/decode_error.py @@ -0,0 +1,99 @@ +"""解码失败 / 文件打不开 —— 最硬的一类问题。 + +为什么重要 +---------- +解码失败意味着这段视频在任何下游都无法使用:训练读取会抛异常、浏览器预览 +会黑屏、导出会失败。而且它常常是文件损坏的前兆 —— 与其等到训练时才发现, +不如在入库后就标出来。 + +严重度 +------ +本项默认 **FAIL**(其余视频检查默认 WARN):能解码是"数据可用"的最低门槛, +不存在"阈值调松就能接受"的余地。 + +opencv 不可用是**环境问题不是数据问题** —— 判 ERROR(处理失败)而不是 FAIL, +避免把环境缺依赖记成数据质量事故。 +""" + +from __future__ import annotations + +from app.processing.cleaning.contract import VIDEO_CAPABLE_MODALITIES +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, Range, PASS, FAIL, ERROR, +) + +# 这些 reason 说明"没检查成",不是"数据坏了" +_ENV_REASONS = {"opencv_unavailable"} + + +@register_check +class VideoDecodeErrorCheck(CleaningCheck): + slug = "video.decode_error" + label = "Decode Errors" + version = "1.0" + description = "Video won't open, or contains undecodable frames" + + requires_channels = ("rgb",) + + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + + device_cards = VIDEO_CAPABLE_MODALITIES + + default_params = {"max_errors": 0} + default_severity = FAIL + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + for stream in ctx.streams_of("rgb"): + report = stream.video or {} + if not report: + continue + + reason = str(report.get("reason") or "") + if reason in _ENV_REASONS: + findings.append(self.finding( + ctx, ERROR, stream=stream.key, + message="OpenCV unavailable, could not check (environment issue, not a data issue)", + metrics={"reason": reason}, + )) + continue + + # 整条流打不开 —— 比丢几帧严重得多 + if reason in ("video_open_failed", "video_metadata_invalid"): + findings.append(self.finding( + ctx, FAIL, stream=stream.key, + message=f"video cannot be opened or has invalid metadata ({reason})", + metrics={"reason": reason, + "error": str(report.get("error") or "")[:200]}, + )) + continue + + count = int(report.get("decode_error_count") or 0) + max_errors = int(self.param(ctx, "max_errors", 0)) + if count <= max_errors: + findings.append(self.finding(ctx, PASS, stream=stream.key, + metrics={"decode_error_count": count})) + continue + + fps = float(report.get("fps") or stream.fps or ctx.fps or 30.0) + frames = [int(item) for item in (report.get("decode_errors") or []) + if isinstance(item, (int, float))] + # 每个失败帧给一个零长度区间 —— 时间轴上就是一个可点击的红点 + marks = tuple( + Range(start_sec=frame / fps, end_sec=frame / fps, + code=self.slug, severity=FAIL, + stream=stream.key, channel="rgb", + start_frame=frame, end_frame=frame, + rule=self.slug, message=f"frame {frame} failed to decode") + for frame in frames[:20] + ) + findings.append(self.finding( + ctx, FAIL, stream=stream.key, + message=f"{count} frames failed to decode" + + (f"(示例:{frames[:5]})" if frames else ""), + metrics={"decode_error_count": count, + "sample_frames": frames[:20]}, + ranges=marks, + )) + return findings diff --git a/processor/app/processing/cleaning/checks/video/frame_drop.py b/processor/app/processing/cleaning/checks/video/frame_drop.py new file mode 100644 index 0000000000000000000000000000000000000000..9ee41de9dc4f8f32b1d1c08935a3478a7995e6c9 --- /dev/null +++ b/processor/app/processing/cleaning/checks/video/frame_drop.py @@ -0,0 +1,82 @@ +"""丢帧 / 帧数对齐 —— 视频帧数与 parquet 行数不一致。 + +为什么重要 +---------- +两者不一致时,按帧号取动作会整体错位;漏帧则会让模型学到"同一帧对应两个 +动作"。真实的采集侧表现是视频比数据少几帧(编码器丢帧)或多几帧(尾部多录)。 + +判定口径 +-------- +容差取 ``max(tolerance_min, data_rows × tolerance_ratio)``: +小批次按绝对帧数兜底(默认 2 帧),大批次按比例(默认 0.5%)。 +超出容差 5 倍判 FAIL,否则 WARN —— 差几帧和差几百帧不是一回事。 +""" + +from __future__ import annotations + +from app.processing.cleaning.contract import VIDEO_CAPABLE_MODALITIES +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, PASS, WARN, FAIL, +) + + +@register_check +class VideoFrameDropCheck(CleaningCheck): + slug = "video.frame_drop" + label = "Frame Drop / Count Alignment" + version = "1.0" + description = "Video frame count differs from data row count" + + requires_channels = ("rgb",) + + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + + device_cards = VIDEO_CAPABLE_MODALITIES + + # 阈值默认值 —— 节点配置可覆盖。 + # 方案要求「阈值不要照搬」:这是平台起点值,必须用人工确认过的好/坏数据验证。 + default_params = {"tolerance_ratio": 0.005, "tolerance_min": 2} + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + for stream in ctx.streams_of("rgb"): + report = stream.video or {} + if not report: + continue + # 打不开/读不了元数据的流不算"丢帧问题",交给 video.decode_error + if report.get("reason") in ("video_open_failed", "video_metadata_invalid"): + continue + + video_frames = int(report.get("frame_count") or 0) + data_rows = int(stream.rows or ctx.data_rows or 0) + if not video_frames or not data_rows: + continue + + diff = abs(video_frames - data_rows) + tolerance = max( + int(self.param(ctx, "tolerance_min", 2)), + int(data_rows * float(self.param(ctx, "tolerance_ratio", 0.005))), + ) + metrics = { + "video_frames": video_frames, + "data_rows": data_rows, + "diff": diff, + "tolerance": tolerance, + } + + if diff <= tolerance: + findings.append(self.finding(ctx, PASS, stream=stream.key, + metrics=metrics)) + continue + + findings.append(self.finding( + ctx, + FAIL if diff > tolerance * 5 else WARN, + stream=stream.key, + message=f"video has {video_frames} frames / data has {data_rows} rows" + f",差 {diff} 帧(容差 {tolerance})", + metrics=metrics, + )) + return findings diff --git a/processor/app/processing/cleaning/checks/video/freeze.py b/processor/app/processing/cleaning/checks/video/freeze.py new file mode 100644 index 0000000000000000000000000000000000000000..817723f574ab3c72eaa3bbdf27c389ff48e97381 --- /dev/null +++ b/processor/app/processing/cleaning/checks/video/freeze.py @@ -0,0 +1,86 @@ +"""冻结 —— 画面连续多帧几乎不变。 + +为什么重要 +---------- +冻结段通常是采集程序卡住、编码器 stall、或 USB 带宽不足导致丢帧后补重复帧。 +它和黑屏不同:画面是"正常的样子",但时间已经不动了。模型会以为任务停滞。 + +注意与"合理的静止"区分 +---------------------- +人拿着夹爪停在物体上方思考时,画面也接近静止。所以本项默认判 **WARN 而非 +FAIL**,并在 metrics 里带上每段时长供人工/AI 复核 —— 方案明确要求 +「阈值不要照搬」,静止多久算异常要按任务节奏定。 +""" + +from __future__ import annotations + +from app.processing.cleaning.contract import VIDEO_CAPABLE_MODALITIES +from app.processing.cleaning.checks import ( + CheckContext, CleaningCheck, register_check, + Finding, Range, WARN, FAIL, +) + + +@register_check +class VideoFreezeCheck(CleaningCheck): + slug = "video.freeze" + label = "Frozen Frame" + version = "1.0" + description = "Frame nearly unchanged across consecutive frames" + + requires_channels = ("rgb",) + + # 属于哪些设备卡片 —— 前端设置面板按它分 tab + + device_cards = VIDEO_CAPABLE_MODALITIES + + default_params = {"min_sec": 2.0, "fail_sec": 10.0} + default_severity = WARN + + def run(self, ctx: CheckContext) -> list[Finding]: + findings: list[Finding] = [] + for stream in ctx.streams_of("rgb"): + report = stream.video or {} + ranges = report.get("freeze_ranges") or [] + if not ranges: + continue + + fps = float(report.get("fps") or stream.fps or ctx.fps or 30.0) + min_sec = float(self.param(ctx, "min_sec", 2.0)) + fail_sec = float(self.param(ctx, "fail_sec", 10.0)) + + converted: list[Range] = [] + longest = 0.0 + worst_status = WARN + for item in ranges: + try: + start, end = float(item[0]), float(item[1]) + except (TypeError, ValueError, IndexError): + continue + span = end - start + if span < min_sec: + continue + longest = max(longest, span) + status = FAIL if span >= fail_sec else WARN + if status == FAIL: + worst_status = FAIL + converted.append(Range( + start_sec=start, end_sec=end, + code=self.slug, severity=status, + stream=stream.key, channel="rgb", + start_frame=int(start * fps), end_frame=int(end * fps), + rule=self.slug, + message=f"frozen for {span:.1f}s", + )) + + if not converted: + continue + + findings.append(self.finding( + ctx, worst_status, stream=stream.key, + message=f"{len(converted)} frozen stretches, longest {longest:.1f}s", + metrics={"segments": len(converted), + "longest_sec": round(longest, 3)}, + ranges=converted, + )) + return findings diff --git a/processor/app/processing/cleaning/collect.py b/processor/app/processing/cleaning/collect.py new file mode 100644 index 0000000000000000000000000000000000000000..c784f4f18192c0f02d64f24681e9fd7309f5f85a --- /dev/null +++ b/processor/app/processing/cleaning/collect.py @@ -0,0 +1,647 @@ +"""证据采集 —— 从批次目录读出检查项需要的一切。 + +检查项**不碰磁盘**(见 ``checks.CheckContext`` 的 IO 注入设计)。所有读取集中在 +这里,好处有两个: + +* 检查项本体是纯逻辑,测试喂假证据即可,不需要真 mp4 / parquet / cv2; +* "读什么、怎么读"只有一处实现,不会出现两个检查项对同一列用不同口径。 + +**本模块只读** —— 不写任何文件、不改任何数据。质检出结论,不出数据。 +""" + +from __future__ import annotations + +import glob +import re +from dataclasses import dataclass, field as dc_field +from pathlib import Path +from typing import Any, Iterable + +import numpy as np + +from app.processing.cleaning.checks import StreamEvidence +from app.processing.cleaning.contract import ( + pose_is_valid, + channels_for, normalize_device_modality, +) + +# SLAM 列的命名:``observation.slam_*`` 是默认相机;双目/多把第二路存成 +# ``observation.<相机名>_slam_*``。这里按**相机**分组,每组里 trajectory 优先。 +# +# 为什么不能写死两个列名:实测 Test94/episode_000003 **只有** gripper_2 前缀的 +# 两列,没有不带前缀的 —— 那种集有 SLAM 数据、却一个 SLAM 检查都不跑,且不报 +# 任何错。同一项目里不同集的列名不一致是常态(见 MODALITY_CHANNELS 的告诫)。 +# +# 两个**不要**匹配进去的近亲: +# observation.slam_trajectory_ns —— 时间戳伴生列,不是位姿 +# processing.umi_slam_action.data_4.action —— 处理缓存列,名字里恰好含 slam +_SLAM_COLUMN_RE = re.compile(r"^observation\.(?:(.+)_)?slam_(trajectory|pose)$") + +# 列名 → 信道。值为**可能的列名**(含历史名)。同一信道的每个存在的列都会 +# 登记成一条流(左右两路力就是两条),流名取列名最后一段。 +# +# ★ 这里**不能**用 `break` 只取第一个 —— 那样左右两路力只剩一路。 +# 要与别名列去重的话看 ``_COLUMN_ALIASES``,别在这里提前退出。 +# +# slam 不在下表里 —— 它按相机分组解析,见 ``_slam_columns``。 +_COLUMN_CHANNELS: dict[str, tuple[str, ...]] = { + "slam": ( + "observation.slam_trajectory", # 高频样本缓冲(优先) + "observation.slam_pose", # 低频快照(旧批才有) + ), + "gripper_state": ("observation.gripper_state",), + "force": ( + "observation.gripper_left_force", + "observation.gripper_right_force", + "observation.gripper_force", + ), + "tactile": ( + "observation.left_glove", + "observation.right_glove", + "observation.glove", + ), + "device_status": ( + "status.left_glove", + "status.right_glove", + "status.glove", + ), + "depth": ("observation.images.depth",), + "action": ("action",), +} + +# 别名列 → 协议列名。**同一份数据的第二套命名**,不新增信道,只是改名。 +# +# 触觉数据有两套名字: +# 采集协议写 observation.left_glove / right_glove +# 导出时统一成 observation.tactile.left / right +# (见 lerobot_export._TACTILE_RENAME,hdf5_export / ai_annotation 读的是后者) +# +# 原始 session 目录里两套可能同时在(导出产物被合并回 canonical);而**只跑过 +# 导出的树里只有后者**。此前只认协议名 —— 那种树上手套信道直接为空,所有手套 +# 检查静默跳过,不报任何错。 +_COLUMN_ALIASES: dict[str, str] = { + "observation.tactile.left": "observation.left_glove", + "observation.tactile.right": "observation.right_glove", +} + +# 各信道的向量宽度 —— 用于把列值切成定长元组。 +# +# ★ 不在表里的信道**不截断**(见 ``vector_series``)。默认截断到某个数字是 +# 危险的:触觉阵列是 16×16=256 维,此前因为这里没有 ``tactile`` 项而落到 +# 3 的兜底值,256 个感应点被静默截成 3 个 —— 检查项只看得到 1% 的阵列。 +# 换手套(或加新阵列通道)时同样会静默丢数据,所以默认必须是"不截断"。 +_CHANNEL_WIDTH: dict[str, int] = { + "gripper_state": 3, + "force": 3, + "action": 7, +} + + +def _apply_column_aliases(frame): + """把别名列归一成协议列名。 + + **只在协议名缺失时改** —— 两套都在时保留协议名:它们是同一份数据, + 登记成两条流会让同一处缺陷被报两次,也让"左右手"的流名对不上。 + (实测 S80C / D435 两套都在时逐元素完全相同。) + """ + rename = {alias: canonical for alias, canonical in _COLUMN_ALIASES.items() + if alias in frame.columns and canonical not in frame.columns} + return frame.rename(columns=rename) if rename else frame + +_DEFAULT_FPS = 30.0 +_MAX_VIDEO_PROBES = 4 + + +@dataclass(frozen=True) +class Evidence: + """一个 episode 的全部证据 —— engine 交给检查项的那一份。""" + + batch_dir: Path + streams: tuple[StreamEvidence, ...] = () + channels: frozenset[str] = frozenset() + modalities: tuple[str, ...] = () + fps: float = _DEFAULT_FPS + rows: int = 0 + columns: tuple[str, ...] = () + parquet: str = "" + notes: dict[str, Any] = dc_field(default_factory=dict) + + @property + def duration_sec(self) -> float: + return (self.rows / self.fps) if self.fps else 0.0 + + +# ── 读文件 ──────────────────────────────────────────────────── + +def _glob(pattern: str, root: Path) -> list[Path]: + return [Path(item) for item in sorted(glob.glob(str(root / pattern)))] + + +def _file_holding(parquets: list[Path], episode_index: int) -> Path | None: + """哪个文件里装着这一集 —— 只读 ``episode_index`` 一列,不整份加载。 + + 文件名对不上时的兜底(历史命名、手工搬过的目录)。返回 None 表示都没有。 + """ + import pyarrow.parquet as pq + + for path in parquets: + try: + column = pq.ParquetFile(path).read(columns=["episode_index"]) + except Exception: + continue + values = column.column("episode_index").to_pylist() + if episode_index in values: + return path + return None + + +def resolve_episode_data(batch_dir: Path, + episode_index: int | None = None) -> tuple[Path | None, bool]: + """定位这一集的数据文件。返回 ``(parquet, 还要不要按行过滤)``。 + + 两种布局的粒度**不一样**,混了就会读出空帧: + + canonical ``data/chunk-*/episode_NNNNNN.parquet`` —— 一集一文件。 + 选对**文件**即可,行全是这一集的。 + 导出产物 ``data/chunk-*/file-NNN.parquet`` —— 一文件多集, + 必须再按 ``episode_index`` 列筛行。 + + ★ 这里曾经是「取 ``parquets[0]`` 再按列过滤」。canonical 布局下第 0 个文件 + 只装第 0 集,于是传任何**别的** ``episode_index`` 都筛出空帧 —— 不报错, + 只是报告变成 ``empty_report``(``episode.status = ERROR``)。在"数据不对 + 就拦回人工审核"的链路里,表现是**除第 0 集外每一集都被卡住**。 + """ + root = Path(batch_dir) + + canonical = _glob("data/chunk-*/episode_*.parquet", root) + if canonical: + if episode_index is None: + return canonical[0], False + # 文件名就是集号(LeRobot canonical 约定,注意编号可能有空洞) + wanted = f"episode_{int(episode_index):06d}.parquet" + for path in canonical: + if path.name == wanted: + return path, False + # 文件名对不上 —— 按列找,别静默返回空 + return _file_holding(canonical, int(episode_index)), False + + multi = _glob("data/chunk-*/*.parquet", root) + if not multi: + return None, False + if episode_index is None: + return multi[0], False + return _file_holding(multi, int(episode_index)), True + + +def load_rows(parquet: Path, filter_episode_index: int | None = None): + """读 parquet;给了 ``filter_episode_index`` 就只取那一集的行。 + + 只在**一文件多集**的布局下才该传它 —— canonical 的一集一文件布局下传了会 + 把这一集自己的行全筛掉。用 ``resolve_episode_data`` 拿到的第二个返回值决定。 + """ + import pyarrow.parquet as pq + + frame = pq.ParquetFile(parquet).read().to_pandas() + if filter_episode_index is not None and "episode_index" in frame.columns: + frame = frame[frame["episode_index"].values == filter_episode_index] + return frame.reset_index(drop=True) + + +# ── 列 → 序列 ───────────────────────────────────────────────── + +def slam_valid_counts(values) -> tuple[int, ...]: + """每帧的有效位姿样本数 —— 空帧即为 0。 + + slam_trajectory 是扁平的 ``[t,x,y,z,qx,qy,qz,qw]×N``。导出产物会把它补齐到 + 定长(补 NaN),所以"有效"= 非 NaN 的 8 元组个数。 + """ + counts: list[int] = [] + for item in values: + if item is None: + counts.append(0) + continue + array = np.asarray(item, dtype=float).ravel() + valid = 0 + for index in range(array.size // 8): + if np.isfinite(array[index * 8:index * 8 + 8]).all(): + valid += 1 + counts.append(valid) + return tuple(counts) + + +def slam_poses(values, counts) -> tuple[tuple, ...]: + """逐帧取【第一个有效样本】的位姿(位置 + 四元数)。 + + ★ 刻意不重用 ``umi_slam_action.poses_from_trajectory`` 的插值重建 —— + 那是生产口径(含时间基拟合与插值)。连续性检查要看的是**原始落盘数据** + 有没有跳变;用插值后的平滑轨迹会掩盖真实跳变。 + """ + poses: list[tuple] = [] + for item, count in zip(values, counts): + if not count: + poses.append(()) + continue + array = np.asarray(item, dtype=float).ravel() + poses.append(tuple(float(v) for v in array[1:8])) + return tuple(poses) + + +def _slam_columns(columns) -> list[str]: + """按**相机**挑出 SLAM 列,每个相机一个:trajectory 优先,回落到 pose。 + + 相机名 = ``observation.`` 与 ``_slam_`` 之间那段,默认相机没有前缀: + + observation.slam_trajectory → 相机 "" + observation.gripper_2_slam_trajectory → 相机 "gripper_2" + + 每个相机只取一个 —— trajectory 是高频样本缓冲、pose 是低频快照, + 两个都有时用前者(与旧行为一致,只是现在**每个相机各自**取一个, + 而不是全局只取一个)。 + """ + by_camera: dict[str, dict[str, str]] = {} + for column in sorted(str(name) for name in columns): + match = _SLAM_COLUMN_RE.match(column) + if not match: + continue + camera, kind = match.group(1) or "", match.group(2) + by_camera.setdefault(camera, {})[kind] = column + + picked: list[str] = [] + for camera in sorted(by_camera): + kinds = by_camera[camera] + picked.append(kinds.get("trajectory") or kinds["pose"]) + return picked + + +def _column_series(values, channel: str, width: int | None = None) -> tuple: + """一列 → 序列。**字符串列原样保留,不试着数值化。** + + ``vector_series`` 对字符串会走 ``np.asarray(..., dtype=float)`` 抛 + ``ValueError``,被 ``except`` 吞掉后**每帧都变成空元组** —— 检查项拿到的是 + "这一列没有数据",看起来像"检查通过"。设备状态列(``status.left_glove``) + 正是字符串,所以这里必须先分流。 + """ + if len(values) and isinstance(values.iloc[0], str): + return tuple("" if item is None else str(item) for item in values) + if width is None: + width = _CHANNEL_WIDTH.get(channel) + return vector_series(values.tolist(), width) + + +def vector_series(values, width: int | None = None) -> tuple[tuple, ...]: + """把一列向量值转成元组序列;空/坏的写成空元组。 + + ``width=None``(默认)**不截断**。截断是静默的 —— 调用方只有显式声明了 + 正确宽度时才该发生(如 action 固定 7 维);未知列宽一律保留整条, + 免得"少了一半感应点"这种问题要等到训练效果不对才发现。 + """ + out: list[tuple] = [] + for item in values: + if item is None: + out.append(()) + continue + try: + array = np.asarray(item, dtype=float).ravel() + if width is not None: + array = array[:width] + out.append(tuple(float(v) for v in array)) + except (TypeError, ValueError): + out.append(()) + return tuple(out) + + +def _video_files(video_dir: Path) -> list[Path]: + """列出视频目录下的 mp4(按名字排序,稳定可预期)。""" + root = Path(video_dir) + if not root.is_dir(): + return [] + return sorted(path for path in root.rglob("*.mp4") if path.is_file()) + + +def videos_for_episode(root: Path, episode_index: int | None) -> list[Path]: + """**这一集自己的**视频文件。取不到就返回空 —— 绝不猜。 + + ★ 不能"把 videos/ 下的 mp4 都拿来用":v3.0 导出把 52 集的视频全放在 + ``videos/chunk-000/<流名>/file-NNN.mp4`` 同一个目录里,随便取几个会拿 + 别的集的画面去比对这一集的数据行数,得出荒谬的丢帧结论(实测出现过 + "视频 9164 帧 / 数据 819 行")。 + + 两种布局各有映射来源: + * canonical —— ``videos/<流名>/chunk-XXX/episode_XXXXXX.mp4``, + 目录本身就对应这一集;有 ``project_dataset.episode_files`` 时用它。 + * 导出产物 —— ``meta/episodes`` 里逐流记着 ``chunk_index``/``file_index``。 + """ + root = Path(root) + if episode_index is None: + # 一集一文件的布局:videos/ 下就是这一集的全部视频 + return _video_files(root / "videos") + + # canonical:走既有索引 + try: + from app.project_dataset import episode_files + entry = episode_files(root, episode_index) or {} + found: list[Path] = [] + for item in entry.get("videos") or []: + # episode_files 返回 (source_key, path) 的形式 + path = item[1] if isinstance(item, (tuple, list)) and len(item) > 1 else item + candidate = Path(path) + if candidate.is_file(): + found.append(candidate) + if found: + return found + except Exception: + pass + + # 导出产物:查 meta/episodes 的逐流索引 + return _videos_from_episode_meta(root, episode_index) + + +def _videos_from_episode_meta(root: Path, episode_index: int) -> list[Path]: + """从 ``meta/episodes`` 的逐流 chunk/file index 拼出视频路径。""" + import pyarrow.parquet as pq + + for path in sorted(glob.glob(str(root / "meta" / "episodes" / "**" / "*.parquet"), + recursive=True)): + try: + frame = pq.ParquetFile(path).read().to_pandas() + except Exception: + continue + if "episode_index" not in frame.columns: + continue + rows = frame[frame["episode_index"].values == episode_index] + if rows.empty: + continue + + # 列名形如 videos/<流名>/chunk_index 与 videos/<流名>/file_index + columns = {str(name) for name in frame.columns} + streams = sorted({ + name[len("videos/"):-len("/chunk_index")] + for name in columns + if name.startswith("videos/") and name.endswith("/chunk_index") + }) + row = rows.iloc[0] + found: list[Path] = [] + for stream in streams: + try: + chunk = int(row[f"videos/{stream}/chunk_index"]) + file_index = int(row[f"videos/{stream}/file_index"]) + except (KeyError, TypeError, ValueError): + continue + candidate = (root / "videos" / f"chunk-{chunk:03d}" / stream + / f"file-{file_index:03d}.mp4") + if candidate.is_file(): + found.append(candidate) + if found: + return found + return [] + + +def _is_depth_key(name: str) -> bool: + """这个流名是不是纯深度流。 + + 复用 ``lerobot_v21.is_depth_source`` —— 深度口径在这个仓库里有好几处实现 + (``device_naming.is_depth_only_key`` 是同一套),但权威定义在那儿。 + 导入失败时回落到保守判断:名字含 depth 且不含 rgb/color。 + """ + try: + from app.lerobot_v21 import is_depth_source + return bool(is_depth_source(name)) + except Exception: + low = str(name or "").lower() + return "depth" in low and not any( + token in low for token in ("rgb", "color", "video")) + + +# ── 主入口 ──────────────────────────────────────────────────── + +def gather_evidence(batch_dir: Path, *, + episode_index: int | None = None, + probe_video: bool = False, + modality_hint: str | Iterable[str] | None = None) -> Evidence: + """读一个 episode 的全部证据。 + + ``probe_video=False``(默认)只读 parquet,毫秒级;视频检查要解码抽样, + 一集几秒到几十秒,按需开。 + + ``modality_hint`` 用于告诉采集层"这台设备是什么"(来自工作流连线的设备 + 卡片)。**可以是多个** —— 一个质检节点上游同时连相机和夹爪时就有多个。 + 给不出时按数据特征推断;两个来源取并集,理由见 ``_detect_modalities``。 + + ★ ``batch_dir`` 必须是**单个 episode 的目录**,且 ``episode_index`` 必须 + 与它匹配。``localstore.get_episode()`` 返回的 ``path`` 是**项目目录** + (几十集共享),直接传进来会拿第 0 集的 parquet 去比全部集的视频 + —— 实测过,会得出"视频 9164 帧 / 数据 819 行"。见 ``videos_for_episode``。 + """ + root = Path(batch_dir) + parquet, filter_rows = resolve_episode_data(root, episode_index) + if parquet is None: + return Evidence(batch_dir=root, notes={"reason": "no_parquet"}) + + # ★ 按行过滤只在"一文件多集"的布局下做。canonical 是一集一文件,文件已经 + # 选对了,再按 episode_index 列筛会把这一集自己的行全筛空 —— 结果是不报 + # 任何错的 empty_report。见 resolve_episode_data。 + frame = load_rows(parquet, episode_index if filter_rows else None) + if frame.empty: + return Evidence(batch_dir=root, parquet=str(parquet.relative_to(root)), + notes={"reason": "empty_episode"}) + + # 别名列先归一成协议名,再算 columns —— 必须在下面所有列名判断之前, + # 否则 observation.tactile.* 会被当成"这列不存在" + frame = _apply_column_aliases(frame) + columns = {str(name) for name in frame.columns} + fps = _detect_fps(root) + rows = len(frame) + streams: list[StreamEvidence] = [] + channels: set[str] = {"time"} + + def _relative(path: Path) -> str: + try: + return str(path.relative_to(root)) + except ValueError: + return str(path) + + # SLAM —— **每个相机一路**,各取 trajectory 优先(见 _slam_columns)。 + # 这里曾经是 `break`:全局只登记一路,双目集第二路的 SLAM 从来没被采集过。 + for column in _slam_columns(columns): + values = frame[column].tolist() + series: dict[str, Any] = {} + if column.endswith("trajectory"): + counts = slam_valid_counts(values) + series["valid_count"] = counts + series["pose"] = slam_poses(values, counts) + # 覆盖率必须走生产口径 —— "插值帧"的定义就是 poses_from_trajectory + # 里本地与相邻格子都够不到的那些,只读原始数据推不出来。 + try: + from app.umi_slam_action import poses_from_trajectory + stamps = (frame["timestamp"].to_numpy(dtype=float) + if "timestamp" in columns + else np.arange(rows) / fps) + diag: dict[str, Any] = {} + poses_from_trajectory(values, stamps, rows, diagnostics=diag) + series["coverage"] = diag + except Exception as exc: + series["coverage_error"] = str(exc)[:200] + else: + poses = vector_series(values, 7) + series["valid_count"] = tuple( + 1 if pose_is_valid(item) else 0 for item in poses) + series["pose"] = poses + # 流名取列名最后一段 —— 多相机时是 "gripper_2_slam_trajectory", + # 单相机时是 "slam_trajectory",两条流不会撞名。 + streams.append(StreamEvidence( + key=column.rsplit(".", 1)[-1], + channel="slam", path=_relative(parquet), + columns=(column,), rows=rows, fps=fps, series=series, + )) + channels.add("slam") + + # 其余标量/向量列 + # + # ★ series 的键一律用【信道名】。检查项按信道取序列(``series["force"]``), + # 如果这里用列名(``series["gripper_left_force"]``),检查项就静默地取不到 + # 数据、全部判通过 —— 而且不报任何错。左右两路力共用 ``force`` 键, + # 靠 stream.key 区分。 + for channel, candidates in _COLUMN_CHANNELS.items(): + if channel == "slam": + continue + for column in candidates: + if column not in columns: + continue + streams.append(StreamEvidence( + key=column.rsplit(".", 1)[-1], channel=channel, + path=_relative(parquet), columns=(column,), rows=rows, fps=fps, + # 走 _column_series:宽度取不到就**不截断**(兜底成某个数字会 + # 静默丢数据),字符串列原样保留(数值化会变成全空元组) + series={channel: _column_series(frame[column], channel)}, + )) + channels.add(channel) + + # 视频 —— ★ 无论检不检查,都要登记成一条流。 + # + # 只登记不检查时 ``video`` 是空 dict(视频检查项会自行跳过),但信道 + # ``rgb`` 必须出现 —— 否则报告会写成"未采集 rgb",而实际上视频就在那儿, + # 只是这一轮没去解码。那会让"训练用途"给出错误的结论。 + video_root = root / "videos" + for path in videos_for_episode(root, episode_index)[:_MAX_VIDEO_PROBES]: + report: dict[str, Any] = {} + if probe_video: + try: + from app.video_quality import _check_stream + report = _check_stream(path, rows, fps) + except Exception as exc: + report = {"passed": False, "reason": "video_check_failed", + "error": str(exc)[:200]} + # 流名 = 流名目录那一层(observation.images.xxx),**不是** chunk-XXX。 + # 两种布局的层级顺序相反,所以不能写死取第几段: + # canonical/v2.1 videos/<流名>/chunk-000/episode_000000.mp4 + # v3.0 videos/chunk-000/<流名>/file-000.mp4 + # 取"路径里第一个不是 chunk-* 的目录段",两边都成立。 + # 写错的话所有视频流会同名(都叫 chunk-000),报告分不清哪路相机, + # 且下游按 stream 去重时会互相覆盖。 + try: + relative = str(path.relative_to(root)) + segments = path.relative_to(video_root).parts[:-1] # 去掉文件名 + key = next((seg for seg in segments + if not seg.startswith("chunk-")), path.stem) + except ValueError: + relative, key = str(path), path.stem + + # ★ 深度流不能算作 rgb 信道。之前一律按 rgb 登记,结果 D435 项目的 + # 深度视频引出了"视频 9164 帧 / 数据 819 行"这种荒谬的丢帧结论 —— + # 那是拿深度流跟 rgb 数据行数比。判定复用 lerobot_v21 的既有口径, + # 不另起一套字符串匹配。 + channel = "depth" if _is_depth_key(key) else "rgb" + streams.append(StreamEvidence( + key=key, channel=channel, path=relative, + rows=rows, fps=fps, video=report, + )) + channels.add(channel) + + modalities = _detect_modalities(channels, modality_hint) + return Evidence( + batch_dir=root, streams=tuple(streams), channels=frozenset(channels), + modalities=modalities, fps=fps, rows=rows, + columns=tuple(sorted(columns)), parquet=_relative(parquet), + ) + + +def _detect_fps(root: Path) -> float: + """从 meta/info.json 取 fps;拿不到就用默认 30。""" + import json + + for relative in ("meta/info.json", "info.json"): + candidate = root / relative + if not candidate.is_file(): + continue + try: + data = json.loads(candidate.read_text(encoding="utf-8")) + value = float(data.get("fps") or 0) + if 0 < value < 1000: + return value + except Exception: + continue + return _DEFAULT_FPS + + +def hint_values(hint: str | Iterable[str] | None) -> list[str]: + """把 hint 归一成字符串列表 —— 调用方有单数(旧调用)也有复数(工作流连线)。""" + if hint is None: + return [] + if isinstance(hint, str): + return [hint] if hint.strip() else [] + try: + return [str(item) for item in hint] + except TypeError: + return [str(hint)] + + +def _infer_modalities(channels: set[str]) -> list[str]: + """只看数据特征能推断出什么。 + + ★ 永远给不出 ``stereo_rgb`` / ``stereo_rgbd_camera`` —— "立体"不是列名里 + 能看见的特征。这两个模态**只能**由工作流连线给出。所以它们是 + ``_detect_modalities`` 取并集、而不是二选一的主要理由。 + """ + if "slam" in channels or "gripper_state" in channels: + return ["gripper_device"] + if "tactile" in channels: + return ["glove_sensor"] + if "depth" in channels: + return ["rgbd_camera"] + if "rgb" in channels: + return ["mono_rgb"] + return [] + + +def _detect_modalities(channels: set[str], + hint: str | Iterable[str] | None) -> tuple[str, ...]: + """这台设备是什么 —— hint(工作流连线)与数据特征取**并集**。 + + 两个来源各有各的盲区,缺一个都会让检查静默不跑: + + 只有 hint → 按数据模态声明的检查项不跑(将来接 ``depth.*`` 时, + D435 数据在但模态被写成 stereo_rgb 就查不到) + 只有推断 → 工作流里那张卡片的 tab 键查不到,**用户在设置面板里配的 + 阈值被静默忽略、一律走默认值**(推断不出 stereo_*) + + 并集对"配置查找"和"检查项筛除"都更宽 —— 而本项目反复踩的坑恰恰是 + "检查项静默不跑",所以宁可宽。 + + hint 在前:``engine.resolve_config`` 按这个顺序合并各设备卡片的配置, + "先出现的优先",工作流连线的顺序才是用户预期的顺序。 + """ + from app.processing.cleaning.contract import DEVICE_MODALITIES + + ordered: dict[str, None] = {} + for raw in hint_values(hint): + canonical = normalize_device_modality(raw) + if canonical in DEVICE_MODALITIES: + ordered.setdefault(canonical, None) + for modality in _infer_modalities(channels): + ordered.setdefault(modality, None) + return tuple(ordered) + + +def channels_from_modalities(modalities) -> tuple[str, ...]: + """设备模态 → 该设备可能提供的信道(转调 contract,供 engine 用)。""" + return channels_for(modalities) diff --git a/processor/app/processing/cleaning/contract.py b/processor/app/processing/cleaning/contract.py new file mode 100644 index 0000000000000000000000000000000000000000..a7dc3fde47b00335e7110ad40ef5ff5c8541bb03 --- /dev/null +++ b/processor/app/processing/cleaning/contract.py @@ -0,0 +1,274 @@ +"""清洗契约 —— 模态、信道、严重度的公共定义。 + +这个文件把"平台里同一个东西有好几个名字"这件事收敛到一处: + + 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, + } diff --git a/processor/app/processing/cleaning/engine.py b/processor/app/processing/cleaning/engine.py new file mode 100644 index 0000000000000000000000000000000000000000..be7c4c2a8cdfbf737d68f3d5341d5f14d07ea45d --- /dev/null +++ b/processor/app/processing/cleaning/engine.py @@ -0,0 +1,302 @@ +"""质检引擎 —— 把检查项串成一次运行,产出四层报告。 + +流程:: + + 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}", + ) diff --git a/processor/app/processing/cleaning/report.py b/processor/app/processing/cleaning/report.py new file mode 100644 index 0000000000000000000000000000000000000000..1925371e5ba0c26e3ee59164c8750ccfe1101595 --- /dev/null +++ b/processor/app/processing/cleaning/report.py @@ -0,0 +1,250 @@ +"""清洗报告组装 —— PDF §5 的「四层结果」。 + + 第一层 数据流 哪条流好、哪条流坏 + 第二层 时间区间 问题发生在哪几秒、哪些帧 + 第三层 episode 这一集整体什么结论 + 第四层 训练用途 能用于什么训练、不能用于什么 + +全部是纯函数 —— 输入是检查项产出的 ``StreamResult`` / ``Finding``, +输出是可 JSON 序列化的 dict。不碰磁盘、不碰 cv2,因此完全可单测。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Iterable + +from app.processing.cleaning.contract import ( + Finding, Range, StreamResult, + PASS, WARN, FAIL, PENDING, ERROR, + SEVERITY_ORDER, SEVERITY_LABELS, worst, is_blocking, +) + +SCHEMA_VERSION = 2 + +# 「训练用途」判定表 —— 声明式,不是 if-else 堆出来的。 +# requires 里的信道必须全部具备,该用途才成立。 +# PDF §7 要求"说明 episode 适合纯视觉、触觉或联合训练",这张表就是那个说明。 +TRAINING_USE_RULES: tuple[dict[str, Any], ...] = ( + {"use": "pure_vision", "label": "Pure vision", + "requires": ("rgb", "time")}, + {"use": "vision_gripper_joint", "label": "Vision + gripper", + "requires": ("rgb", "gripper_state", "time")}, + {"use": "slam_trajectory", "label": "SLAM trajectory", + "requires": ("slam", "time")}, + {"use": "vision_tactile_joint", "label": "Vision + tactile", + "requires": ("rgb", "tactile", "time")}, + {"use": "bimanual_tactile", "label": "Bimanual tactile", + "requires": ("tactile", "time")}, +) + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def build_timeline(streams: Iterable[StreamResult | dict]) -> list[dict]: + """把各条流里的区间拍平成一条时间轴,按时间排序去重。 + + 详情页要一次拉完整条时间轴,而 streams 是嵌套的 —— 所以顶层冗余一份。 + 单一来源、单一方向(从 streams 派生),有测试保证一致。 + """ + items: list[dict] = [] + for stream in streams: + if isinstance(stream, StreamResult): + for item in stream.ranges: + items.append(_range_row(item.to_dict(), stream.stream)) + elif isinstance(stream, dict): + for item in stream.get("ranges") or []: + if isinstance(item, dict): + items.append(_range_row(item, str(stream.get("stream") or ""))) + items.sort(key=lambda row: (row["start_sec"], row["end_sec"], row["code"])) + deduped: list[dict] = [] + for row in items: + if deduped and all( + row[key] == deduped[-1][key] + for key in ("start_sec", "end_sec", "code", "stream") + ): + continue + deduped.append(row) + return deduped + + +def _range_row(item: dict, stream: str) -> dict: + return { + "stream": item.get("stream") or stream, + "channel": item.get("channel") or "", + "start_sec": float(item.get("start_sec") or 0.0), + "end_sec": float(item.get("end_sec") or 0.0), + "start_frame": item.get("start_frame"), + "end_frame": item.get("end_frame"), + "code": str(item.get("code") or ""), + "severity": str(item.get("severity") or WARN), + "rule": str(item.get("rule") or ""), + "message": str(item.get("message") or ""), + } + + +def rollup_episode(streams: Iterable[StreamResult | dict], + findings: Iterable[Finding | dict] = ()) -> dict: + """第三层:整集结论。纯函数。 + + 取所有流 + 所有 finding 里最严重的那个状态。 + """ + statuses: list[str] = [] + failed: list[str] = [] + warned: list[str] = [] + pending: list[str] = [] + + for stream in streams: + if isinstance(stream, StreamResult): + name, status = stream.stream, stream.status + else: + name, status = str(stream.get("stream") or ""), str(stream.get("status") or PASS) + statuses.append(status) + if is_blocking(status): + failed.append(name) + elif status == PENDING: + pending.append(name) + elif status == WARN: + warned.append(name) + + for finding in findings: + status = finding.status if isinstance(finding, Finding) else str(finding.get("status") or PASS) + statuses.append(status) + + overall = worst(statuses) if statuses else PASS + pieces: list[str] = [] + if failed: + pieces.append(f"{len(failed)} stream(s) failed") + if warned: + pieces.append(f"{len(warned)} stream(s) with warnings") + if pending: + pieces.append(f"{len(pending)} stream(s) pending review") + return { + "status": overall, + "status_label": SEVERITY_LABELS.get(overall, overall), + "failed_streams": failed, + "warn_streams": warned, + "pending_streams": pending, + "summary": "; ".join(pieces) if pieces else "All checks passed", + } + + +def rollup_training_use(channels: Iterable[str], + episode_status: str = PASS) -> list[dict]: + """第四层:这个 episode 能用于哪些训练。纯函数。 + + 信道不具备 → 该用途判 fail("未采集"),这是**用途维度**的失败, + 不代表数据本身坏 —— 所以 reason 写清楚是"未采集"而不是"质量差"。 + """ + have = {str(item) for item in channels} + rows: list[dict] = [] + for rule in TRAINING_USE_RULES: + required = tuple(rule["requires"]) + missing = [channel for channel in required if channel not in have] + if missing: + rows.append({ + "use": rule["use"], + "label": rule["label"], + "status": FAIL, + "reason": f"not collected: {', '.join(missing)}", + "requires": list(required), + "missing": missing, + }) + else: + rows.append({ + "use": rule["use"], + "label": rule["label"], + "status": episode_status, + "reason": "" if episode_status == PASS else "data quality problem", + "requires": list(required), + "missing": [], + }) + return rows + + +def passed_for(status: str) -> bool: + """该状态是否算"通过"(只有 PASS 算通过,WARN 不算)。""" + return status == PASS + + +def build_report(streams: Iterable[StreamResult], + findings: Iterable[Finding] = (), + *, + episode_id: str = "", + project: str = "", + modalities: dict[str, Any] | None = None, + channels: Iterable[str] = (), + ruleset_revision: str = "", + template_source: str = "", + elapsed_ms: int = 0) -> dict: + """组装完整报告。纯函数 —— 唯一的副作用是 ``_utcnow()``。""" + stream_list = list(streams) + finding_list = list(findings) + episode = rollup_episode(stream_list, finding_list) + use_channels = {str(item) for item in channels} + for stream in stream_list: + use_channels.update(stream.channels) + + return { + "schema_version": SCHEMA_VERSION, + "episode_id": episode_id, + "project": project, + "computed_at": _utcnow(), + "elapsed_ms": int(elapsed_ms), + "ruleset_revision": ruleset_revision, + "template_source": template_source, + + # 模态清单 —— PDF §1 的「数据清单」 + "modalities": dict(modalities or {}), + + # 判定总入口:只有 PASS 算通过 + "passed": passed_for(episode["status"]), + + # 四层结果 + "streams": [ + item.to_dict() if isinstance(item, StreamResult) else dict(item) + for item in stream_list + ], + "ranges": build_timeline(stream_list), + "episode": episode, + "training_use": rollup_training_use(use_channels, episode["status"]), + + "findings": [ + item.to_dict() if isinstance(item, Finding) else dict(item) + for item in finding_list + ], + } + + +def empty_report(*, episode_id: str = "", project: str = "", + reason: str = "no_data") -> dict: + """没有任何数据可检查时的占位报告。 + + ``passed`` 恒为 False —— 没有数据不等于数据合格(fail closed)。 + """ + return { + "schema_version": SCHEMA_VERSION, + "episode_id": episode_id, + "project": project, + "computed_at": _utcnow(), + "elapsed_ms": 0, + "ruleset_revision": "", + "template_source": "", + "modalities": {}, + "passed": False, + "streams": [], + "ranges": [], + "episode": { + "status": ERROR, + "status_label": SEVERITY_LABELS[ERROR], + "failed_streams": [], + "warn_streams": [], + "pending_streams": [], + "summary": reason, + }, + "training_use": [], + "findings": [], + "reason": reason, + } diff --git a/processor/app/processing/cleaning/store.py b/processor/app/processing/cleaning/store.py new file mode 100644 index 0000000000000000000000000000000000000000..7b245ab84c338ba387757394639d6353c001966f --- /dev/null +++ b/processor/app/processing/cleaning/store.py @@ -0,0 +1,189 @@ +"""质检报告落盘 —— 写进 episode state 的 ``cleaning_report`` 字段。 + +**报告与状态流转是同一次写入**:判 FAIL/ERROR 的批次会被拦回 +``to_review``(人工审核),并留下 ``cleaning_summary.blocking`` 这个粘性标记。 + +只写报告、不碰状态的方案看着更"干净",但挡住不坏数据 —— 自动批准散在 +``ai_annotation`` / ``video_quality`` 两处门禁里,它们各看各的报告, +谁都不会读质检结论。判 FAIL 的集只要视频干净,照样被自动批准放行。 + +另外**不动** ``deleted`` / ``rejected`` 这类状态:质检不能把用户删掉或否决过的 +批次复活。 + +放在 ``state/episode_states/.json`` 而不是项目数据集里: +后者是给 LeRobot 消费的干净树(``project_dataset`` 明确不建 ``processed/``、 +``meta/processing/``),而质检报告是 episode 级的可变状态 —— ``state/`` 正是 +它的既有归属,``ai_quality_report`` / ``video_quality_report`` 也都在这。 + +读走 ``localstore.read_episode_state``(带内存缓存),写走 +``write_episode_state``(原子替换 + 同步更新缓存)—— 两条路径都自带锁, +不要自己读写那个文件。 +""" + +from __future__ import annotations + +from typing import Any + +from app.processing.cleaning.contract import is_blocking + +# episode state 里放质检报告的字段名。 +REPORT_FIELD = "cleaning_report" +# 只放摘要,完整报告另存 —— 报告里可能有几十条区间,全塞进 state 会让 +# 每次 ``read_episode_state`` 都要解析几百 KB。完整报告见 ``reports_dir()``。 +SUMMARY_FIELD = "cleaning_summary" + +# 完整报告单独存一个目录,避免污染 episode state +_REPORTS_SUBDIR = "quality_reports" + +# 允许被质检拦回人工审核的状态。 +# +# **不含 ``to_review``**(本来就在那儿,再写一次是空操作)、也不含 ``deleted`` +# 与 ``rejected`` —— 质检不能把用户删掉/否决过的批次复活。 +_BLOCKABLE_STATUSES = frozenset({ + "processing", "completed", "reviewed", "approved", "failed", +}) + + +def _reports_dir(): + from app.localstore import STATE_ROOT + return STATE_ROOT / _REPORTS_SUBDIR + + +def summary_of(report: dict) -> dict[str, Any]: + """从完整报告里抽一个轻量摘要 —— 列表页/徽标只读这个。 + + ``blocking`` 是**给自动批准路径看的**闸门(见 ``is_approval_blocked``), + 不是展示字段:判 FAIL/ERROR 才为真,WARN 只是警告、不拦。 + """ + episode = report.get("episode") or {} + return { + "schema_version": report.get("schema_version"), + "passed": bool(report.get("passed")), + "blocking": is_blocking(str(episode.get("status") or "")), + "status": episode.get("status"), + "status_label": episode.get("status_label"), + "summary": episode.get("summary"), + "counts": { + "fail": len(episode.get("failed_streams") or []), + "warn": len(episode.get("warn_streams") or []), + "pending": len(episode.get("pending_streams") or []), + }, + "ranges": len(report.get("ranges") or []), + "ruleset_revision": report.get("ruleset_revision"), + "computed_at": report.get("computed_at"), + "reason": report.get("reason"), + } + + +def is_approval_blocked(state: dict) -> bool: + """该集的质检结论是否禁止**自动批准**。 + + 自动批准(``to_review`` → ``reviewed``)散在好几处:AI 标注的质量门禁 + (``ai_annotation._set_ai_quality_state``)、视频门禁 + (``video_quality._apply_video_quality_result``)。它们只看**自己的** + 报告,不看数据质检的结论 —— 于是质检判 FAIL、视频门禁判 PASS 时,坏数据 + 会被自动放行。 + + ★ 为什么不能只靠"把 status 推回 to_review": + 几条门禁是**并发**跑的(都在 run 完成回调里 create_task),写的先后没有 + 保证。若质检先把状态推回 to_review、视频门禁随后又置成 reviewed, + 拦截就白做了。所以质检除了推状态,还留下这个**粘性标记**,让所有自动 + 批准路径都先看一眼。 + + 标记不需要手工清除:下一次质检会整份覆盖 ``cleaning_summary``, + 数据修好后 ``blocking`` 自然变回 False。 + """ + summary = (state or {}).get(SUMMARY_FIELD) or {} + return bool(summary.get("blocking")) + + +def save_report(episode_id: str, report: dict) -> bool: + """把报告写进 episode state。返回是否写入成功。 + + **不改 ``status``** —— 只设 ``cleaning_report`` / ``cleaning_summary`` / + ``cleaning_at`` 三个字段。 + + episode 不存在时静默跳过(外键不存在就别凭空造一条 state)。 + """ + from app.localstore import mutate_episode_state, _write_json + + key = str(episode_id) + if not key: + return False + + # 完整报告先单独落盘 —— 它可能有几十条区间,塞进 episode state 会让每次 + # read_episode_state 都要解析几百 KB。放在下面临界区**外面**:这是个大文件 + # 写入(在 NAS 上可能是几十毫秒),不该占着状态锁。 + try: + _write_json(_reports_dir() / f"{key}.json", report) + except Exception: + pass # 摘要照样进 state,完整报告失败不影响 + + summary = summary_of(report) + + def _apply(state: dict) -> bool: + state[REPORT_FIELD] = report + state[SUMMARY_FIELD] = summary + state["cleaning_at"] = report.get("computed_at") + + # 数据不对 → 拦回人工审核。与写报告**同一次锁内 RMW**:分成两次写的话 + # 中间那个窗口里,另一条并发的门禁可能把状态改成 reviewed。 + if summary.get("blocking"): + current = str(state.get("status") or "") + if current in _BLOCKABLE_STATUSES: + state["status"] = "to_review" + state["approved_at"] = None + return True + + # 走锁内 RMW:跑完工作流后 video_quality 的门禁任务与本任务是并发写的, + # 手写 read→write 会让其中一方的字段被另一方的旧快照覆盖(详见 + # localstore.mutate_episode_state 的注释)。episode 不存在时返回 None, + # 保持"不凭空造 state"的既有行为。 + return bool(mutate_episode_state(key, _apply)) + + +def load_report(episode_id: str) -> dict | None: + """读完整报告(不存在返回 None)。""" + from app.localstore import _read_json + + return _read_json(_reports_dir() / f"{episode_id}.json") + + +def load_summary(episode_id: str) -> dict | None: + """读摘要(从 episode state 取,快)。""" + from app.localstore import read_episode_state + + return (read_episode_state(str(episode_id)) or {}).get(SUMMARY_FIELD) + + +def list_reports(limit: int = 0) -> list[dict]: + """列出全部摘要,按时间倒序 —— 给质量列表页用。 + + 只扫 ``quality_reports/`` 目录,不 join ``scan_sessions()`` —— 后者在 + SSHFS 上冷读极慢,而报告是自包含的(项目名就在里面)。 + """ + from app.localstore import _read_json + + directory = _reports_dir() + if not directory.is_dir(): + return [] + items: list[dict] = [] + for path in directory.glob("*.json"): + report = _read_json(path) + if not isinstance(report, dict): + continue + item = summary_of(report) + item["episode_id"] = report.get("episode_id") or path.stem + item["project"] = report.get("project") + items.append(item) + items.sort(key=lambda item: str(item.get("computed_at") or ""), reverse=True) + return items[:limit] if limit else items + + +def forget_report(episode_id: str) -> None: + """删除报告(episode 被删除时调用)。""" + for path in (_reports_dir() / f"{episode_id}.json",): + try: + path.unlink(missing_ok=True) + except OSError: + pass diff --git a/processor/app/processing/cleaning/trigger.py b/processor/app/processing/cleaning/trigger.py new file mode 100644 index 0000000000000000000000000000000000000000..6b90a17e65d824b54265860928dc8142864dcdb9 --- /dev/null +++ b/processor/app/processing/cleaning/trigger.py @@ -0,0 +1,225 @@ +"""质检触发 —— 把纯引擎接到运行时(工作流跑完之后)。 + +三层分工,别混: + + 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 diff --git a/processor/app/processing/modules/ai_quality_review.py b/processor/app/processing/modules/ai_quality_review.py deleted file mode 100644 index c5438601c4fe86974e65a5ee6b9712f251e36847..0000000000000000000000000000000000000000 --- a/processor/app/processing/modules/ai_quality_review.py +++ /dev/null @@ -1,36 +0,0 @@ -"""AI Quality Review — lightweight media and annotation quality gate. - -The actual check runs after the workflow result is persisted. It checks video -decode/frame continuity/black screens/freezes and, when connected after AI -Annotation, annotation coverage too. This node keeps the normal artifact -contract so existing worker graphs remain compatible. The service-level gate -moves failed/incomplete batches to the existing Reviewing queue and only -allows a passed result to become Approved. -""" - -from app.processing import ProcessingModule, JobContext, ArtifactRef -from app.processing.registry import register -from app.processing.theme import REVIEW_COLOR - - -@register -class AIQualityReviewModule(ProcessingModule): - slug = "ai_quality_review" - version = "1.0" - category = "review" - label = "AI Quality Review" - icon = "ant-design:robot-outlined" - color = REVIEW_COLOR - inputs = ({"key": "data", "label": "Quality Review Target"},) - outputs = ({"key": "reviewed", "label": "Reviewed Data"},) - default_config = {"mode": "gate"} - execution_target = "server" - capabilities = ("quality_review",) - - def run(self, ctx: JobContext) -> dict[str, ArtifactRef] | None: - # The workflow executor still receives the upstream artifact. The - # episode-level gate is applied by the AI annotation service after its - # persisted ranges have been validated. - if not ctx.incoming: - ctx.skip("No upstream data — skipped") - return dict(ctx.incoming) diff --git a/processor/app/processing/modules/data_quality.py b/processor/app/processing/modules/data_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..6b66e34dcaea94b1580119feaac23e4ddb4beb4a --- /dev/null +++ b/processor/app/processing/modules/data_quality.py @@ -0,0 +1,126 @@ +"""Data Cleaning —— 数据清洗(质检):一个节点统管确定性与 AI 两类检查。 + +显示名是 Data Cleaning;slug 仍是 ``data_quality``(持久化契约,改它会让已保存 +工作流的 nodeType 对不上)。两者不一致是有意的 —— 见下方端口 key 的同类说明。 + +本节点由两个旧节点合并而来,两者功能高度重叠: + + ai_quality_review 视频检查(黑屏/冻结/丢帧/解码) + AI 标注覆盖率 + data_cleaning 视频检查 + UMI/手套/EGO 检查(第 2 期接入) + +历史原因:视频检查最早"寄居"在 ``ai_quality_review`` 里,好让纯视频工作流 +也有质量门禁(见 ``ai_annotation.py`` 的 ``video_quality_review_node_config`` +docstring)。现在合并成一张卡片,语义与配置都归一处。 + +**两个旧 slug 都作为别名保留**(``workflow_types.LEGACY_TO_CANONICAL``), +已保存的工作流打开时自动迁移,连线不受影响 —— 端口的 key 刻意保持不变: + + 输入 data 所有旧连线都用它 + 输出 reviewed 旧连线用它接 lerobot_export + +检查分两层,与清洗方案 PDF §4 的两栏一一对应: + + 机器可直接判断(确定性、免费、可每集跑) + ├─ 视频:解码 / 黑屏 / 冻结 / 丢帧 + ├─ UMI:帧对齐 / action / 夹爪量程 / SLAM 连续性 + ├─ 手套:全零 / 死点 / 漂移 / 饱和 + └─ EGO:曝光 / 关键点缺失 / 左右手 + + 需联合判断或复核(有成本、可选) + └─ AI 标注覆盖率;将来的 VLM 语义复核 + +检查项的算法实现与工作流适配器分开,与 ``app/processing/black_glove`` 同构: + + app/processing/cleaning/ ← 契约 + 检查项 + 报告组装 + app/processing/modules/data_quality.py ← 本文件:artifact 契约 + 端口 + 配置 + +新增检查项 = 在 ``app/processing/cleaning/checks/`` 下放一个 .py,本文件不用改。 + +**只出结论,不产出数据** —— 原始数据一字不改,也不生成 clean_v1 之类的副本。 +区间信息完整保留在报告的 ``ranges`` 里,下游按 ``training_use`` 过滤即可。 + +数据不对时**卡在人工审核**:判 FAIL/ERROR 的批次会被推回 ``to_review`` +(就是审核页的入口),并在 ``cleaning_summary.blocking`` 上留一个粘性标记, +让 AI 标注 / 视频门禁的自动批准路径让路。WARN 只记录、不拦。 +""" + +from __future__ import annotations + +from app.processing import ProcessingModule, JobContext, ArtifactRef +from app.processing.registry import register +from app.processing.theme import REVIEW_COLOR + + +@register +class DataQualityModule(ProcessingModule): + slug = "data_quality" + version = "1.0" + category = "review" + label = "Data Cleaning" + # 必须取自 web/static/iconify-preload.js 的白名单 —— 图标是离线预载的, + # 不在表内的名字 iconify-icon 会去 api.iconify.design 拉,调色板一次渲染 + # 全部节点,一个漏网图标就能让整块面板卡住。check-circle 表示"校验", + # 与同组 review 节点的 eye(human) 不冲突。 + # 改图标后请跑 scripts/check_icon_preload.sh。 + icon = "ant-design:check-circle-outlined" + color = REVIEW_COLOR + + # 端口 key 是持久化契约 —— 取值必须与合并前的 ai_quality_review 逐字一致, + # 否则已保存工作流的连线会因 sourceHandle/targetHandle 对不上而失效。 + # 详见 workflow_types.py 里前端对 ``out.key === inp.key`` 的校验。 + inputs = ({"key": "data", "label": "Data"},) + outputs = ({"key": "reviewed", "label": "Reviewed Data"},) + + # 配置结构(前端设置弹窗按设备卡片写入):: + # + # {"devices": { + # "gripper_device": { + # "checks": { + # "umi.slam_continuity": {"enabled": true, + # "params": {"max_linear_mps": 3.0}}, + # "video.freeze": {"enabled": false} + # }}}} + # + # 引擎侧由 ``cleaning.engine.resolve_config()`` 解析。 + default_config = {"devices": {}} + + # ★ 刻意留空 —— 配置全部走专用弹窗(DeviceQualityModal),不走通用 + # config_schema 渲染。 + # + # 通用那套是**平铺字段列表**,表达不了"按设备卡片分组 + 每项带阈值"的 + # 嵌套结构;硬塞会变成一长串 enable_xxx 开关。曾短暂用过那套,结果是 + # 三个阈值在节点里和检查项里各存一份(frame_tolerance 与 + # video.frame_drop.tolerance_ratio 同义),迟早不一致。 + # + # 现在阈值只有一处定义:检查项的 ``default_params``;节点只存"覆盖值"。 + config_schema = () + + # 真实检查在 run 完成回调里跑(那时才能读到合并后的 episode 数据), + # 与 human_review / 旧 ai_quality_review 的分工一致。 + # 注:该字段当前无消费方,仅作语义标注(全仓 grep 只见 schema 与文档)。 + execution_target = "server" + capabilities = ("data_quality", "quality_gate", "quality_review") + + def run(self, ctx: JobContext) -> dict[str, ArtifactRef] | None: + """透传上游 artifact。上游没连 → skipped。 + + **真正的检查不在 ``run()`` 里跑** —— 在 worker 的 run 完成回调里 + (``api/worker.py`` 调 ``cleaning.trigger.spawn_cleaning_for_run``)。 + + 为什么不在 ``run()``:DAG 执行期 worker 是在 staging 目录里干活、 + 产物要等 ``_publish_episode_result`` 才合并回批次目录。在这一步读 + 批次目录,拿到的是**上一轮**的数据 —— 尤其 ``action`` 列,它由 + ``umi_slam_action`` 派生产出,读早了会全是采集端的零占位。 + 完成回调时数据已经合并完毕,与 ``human_review`` / 旧 + ``ai_quality_review`` 的分工一致。 + + 检查所需的数据**不靠端口传** —— 完成回调手上就有 + ``localstore.get_episode()`` 的记录(``path`` + ``episode_index``), + 检查项直接读批次目录。 + + ★ 进来的是 ``episode_index`` 不是可选的:``path`` 是**项目目录**, + 几十集共享,漏传会拿第 0 集的数据去比全部集的视频。 + """ + if not ctx.incoming: + ctx.skip("No upstream data — skipped") + return dict(ctx.incoming) diff --git a/processor/app/project_dataset.py b/processor/app/project_dataset.py index cba14102fc16b2303512255fdd37f3ef5c9beb04..34b7806dfe5b41c446443daa7b97432dace342bc 100644 --- a/processor/app/project_dataset.py +++ b/processor/app/project_dataset.py @@ -102,6 +102,17 @@ def write_project_episode_index(project_root: Path, # The legacy flat JSONL index is intentionally not generated anymore. (project_root / "meta" / "episodes.jsonl").unlink(missing_ok=True) + # ★ 必须失效缓存 —— 本函数是**索引的唯一写入点**,而 project_episode_rows() + # 读的是带 60s TTL 的模块级缓存。不失效的话,同一次调用里"写完再读"会拿到 + # 删除前的旧行。 + # + # 实测后果(2026-09-11 引入缓存时漏掉这步,删除从此全坏): + # delete_project_episode: 读一次(预热缓存) → 删文件 → 写索引 → 校验 + # 校验又调 project_episode_rows → 旧缓存 → 报 "missing data" → RuntimeError + # →「清空回收站」500。盘上的索引其实是对的,纯粹是缓存没跟上。 + # (delete_episode 末尾也会 invalidate_session_cache(),但那要等异常之后。) + invalidate_project_cache(project_root) + def is_project_dataset(root: Path) -> bool: root = Path(root) @@ -1402,13 +1413,30 @@ def merge_normalized_episode(project_root: Path, episode_root: Path, } +# 人工/脚本留下的备份与临时文件 —— 不是删除或合并操作产生的,不该算到校验头上。 +# +# ★ 为什么必须放过它们:``delete_episode`` 在**删完文件之后**才跑本校验,校验失败就 +# 抛 RuntimeError → 500,而「清空回收站」是循环处理的,一个项目失败整轮中止。 +# 实测踩过:两个 D435 项目的 meta/ 里各有一个 ``info.json.bak-20260908-144550`` +# (9-08 留下的),于是**每次清空都 500、什么都删不掉**,而前端没有错误分支, +# 用户看到的是"点了没反应"。一个备份文件不该有这种杀伤力。 +_IGNORED_ENTRY_SUFFIXES = (".bak", ".orig", ".tmp", ".save", "~") + + +def _is_ignorable_entry(name: str) -> bool: + """备份/临时文件,或隐藏文件 —— 校验一律不看。""" + if name.startswith("."): + return True + return name.endswith(_IGNORED_ENTRY_SUFFIXES) or ".bak-" in name + + def verify_project_dataset(project_root: Path) -> dict[str, Any]: root = Path(project_root) rows = project_episode_rows(root) errors: list[str] = [] unexpected = sorted( path.name for path in root.iterdir() - if path.name not in _CANONICAL_ROOTS and not path.name.startswith(".") + if path.name not in _CANONICAL_ROOTS and not _is_ignorable_entry(path.name) ) if root.is_dir() else [] errors.extend(f"unexpected project root entry: {name}" for name in unexpected) expected_meta = {"info.json", "stats.json", "tasks.json", "episodes"} @@ -1416,7 +1444,7 @@ def verify_project_dataset(project_root: Path) -> dict[str, Any]: if meta_root.is_dir(): unexpected_meta = sorted( path.name for path in meta_root.iterdir() - if path.name not in expected_meta and not path.name.startswith(".") + if path.name not in expected_meta and not _is_ignorable_entry(path.name) ) errors.extend(f"unexpected meta entry: {name}" for name in unexpected_meta) for row in rows: @@ -2074,11 +2102,25 @@ def append_project_episode(project_root: Path, episode_root: Path, existing = next((row for row in rows if str(row.get("episode_id") or row.get("source_batch") or "") == str(episode_id)), None) if existing is None: - target_episode_index = max( - [_episode_index(row.get("episode_index")) for row in rows - if _episode_index(row.get("episode_index")) is not None], - default=-1, - ) + 1 + used = {_episode_index(row.get("episode_index")) for row in rows + if _episode_index(row.get("episode_index")) is not None} + # ★ 优先沿用**名字里的编号**,让存储索引与公开 ID 一致。 + # + # 此前一律 max(used)+1,于是"名字说 000005、索引是 3"这种对不上的 + # 情况会出现 —— 实测 119 个带编号批次里 **21 个**中招。它的直接 + # 危害在 _match_episode_row 的名字回退:按名字推出索引后会撞上 + # 别的批次(模拟:删 ..._000002 时若它自己的行已缺失,会去删 + # index=2 那个批次的数据和视频)。 + # + # 编号允许空洞(见本模块 delete_project_episode 的说明 + # "gaps are valid"),所以直接采用名字里的编号是安全的。 + # 两种情况仍回落到 max+1:名字里没有编号;或该编号已被别的批次 + # 占用(撞号)。 + named = episode_index_from_name(episode_id) + if named is not None and named not in used: + target_episode_index = named + else: + target_episode_index = max(used, default=-1) + 1 chunk_index = episode_chunk_for_index(target_episode_index) else: if not replace: @@ -2104,6 +2146,17 @@ def append_project_episode(project_root: Path, episode_root: Path, if project_existed and backup_root.exists(): backup_root.rename(project_root) raise + # ★ 换入之后必须失效【project_root】的缓存,不能只靠 + # write_project_episode_index 里那次 —— 那次失效的是 **temp_root** + # 的 key,而内容是靠 rename 换过来的。 + # + # 不失效的后果有两条: + # (1) 紧跟的 verify_project_dataset(project_root) 读到的还是**追加前** + # 的旧行 —— 校验等于在验旧树,"通过"是空头支票(实测:连续三次 + # append 全部通过,而盘上索引早已是 [0,1,2,5,6,7]); + # (2) 缓存有 60 秒的盲目 TTL(TTL 内连指纹都不查),上传后前端 + # 最长 60 秒看不到新批次。 + invalidate_project_cache(project_root) result["verification"] = verify_project_dataset(project_root) result["backup"] = str(backup_root) if project_existed else None return result diff --git a/processor/app/routes/export.py b/processor/app/routes/export.py index 9ec1461ae378a999e8eb5a1d0bb8996d97a3b0ec..ee2702c7d0b90ef06888c1a09e3645bee5fb3b73 100644 --- a/processor/app/routes/export.py +++ b/processor/app/routes/export.py @@ -38,6 +38,9 @@ router = APIRouter(prefix="/api/v1/export", tags=["export"]) EXPORTS_DIR = STATE_ROOT / "exports" EXPORT_TASKS_DIR = STATE_ROOT / "export_tasks" +# 可训练的数据集 —— 与 EXPORTS_DIR 分开:那边是按 run_id 命名的**处理产物** +# (每条一集),这边是**能拿去训练的成品**,训练页只列这个目录。 +DATASETS_DIR = STATE_ROOT / "datasets" # 导出页/批量导出的构建目录与 zip 放本地盘:同一份视频数据不再在 # NAS(SSHFS ~28MB/s)上读写三遍,只保留一次源文件读取。 LOCAL_EXPORTS_DIR = settings.upload_staging_root / "exports" @@ -484,6 +487,38 @@ async def batch_download_sessions(request: Request): # ── 导出任务(JSON,后台打包)───────────────────────── +def _persist_trainable_dataset(export_root: Path, dataset_name: str) -> Path | None: + """把导出好的数据集落到 ``state/datasets/<名字>/``,让它能当训练输入。 + + 原来是打包完直接 ``rmtree``(省本地空间)—— 结果导出的成果只剩一个 zip, + 磁盘上什么都没有,训练页根本无从列起。 + + **交换式替换**,不是"先删旧的再搬新的":先把新数据集搬进 ``.staging``, + 再把旧的挪到 ``.old``,然后 rename 上位,最后删 ``.old``。任何一步失败都不会 + 让目标位置处于"半个数据集"的状态 —— 直接 rmtree 再 move 的话,搬到一半 + 失败就两头落空。 + + 返回落位后的路径;``export_root`` 里没有 ``meta/info.json``(不是数据集)时 + 返回 None,调用方照旧删掉临时目录。 + """ + if not (export_root / "meta" / "info.json").is_file(): + return None + + DATASETS_DIR.mkdir(parents=True, exist_ok=True) + target = DATASETS_DIR / dataset_name + staging = DATASETS_DIR / f".{dataset_name}.staging" + previous = DATASETS_DIR / f".{dataset_name}.old" + + shutil.rmtree(staging, ignore_errors=True) + shutil.move(str(export_root), str(staging)) + shutil.rmtree(previous, ignore_errors=True) + if target.exists(): + target.rename(previous) + staging.rename(target) # 同盘 rename,原子 + shutil.rmtree(previous, ignore_errors=True) + return target + + def _load_task(job_id: str) -> dict | None: return _read_json(EXPORT_TASKS_DIR / f"{job_id}.json", None) @@ -573,8 +608,9 @@ def _run_export_task(job_id: str, episode_ids: list[str], export_format: str, ] if blocked: raise RuntimeError("Some episodes are still in Reviewing") - # 构建与 zip 都在本地盘进行(NAS 只读一次源文件);zip 是最终 - # 产物,构建目录打包完成后即删除以节省本地空间。 + # 构建与 zip 都在本地盘进行(NAS 只读一次源文件)。产物两样都留: + # zip 供下载,数据集目录(LeRobot 分支)供训练页选用 —— 后者走 + # _persist_trainable_dataset 落到 state/datasets/,不再是"打完包就删"。 dest = LOCAL_EXPORTS_DIR / f"{job_id}.zip" dest.parent.mkdir(parents=True, exist_ok=True) export_root = LOCAL_EXPORTS_DIR / f"{dataset_name}-{job_id[:8]}" @@ -591,12 +627,17 @@ def _run_export_task(job_id: str, episode_ids: list[str], export_format: str, progress_callback=_on_episode) _set_progress(0.75) _zip_directory(export_root, dest) + # 打包后**留下**数据集,不要删 —— 训练页的数据集列表就是从这里读的 + # (state/datasets/)。只有它压根不是数据集时才清掉临时目录。 + if _persist_trainable_dataset(export_root, dataset_name) is None: + shutil.rmtree(export_root, ignore_errors=True) _set_progress(0.95) else: _set_progress(0.2) _zip_episode_selection([str(e) for e in episode_ids], dest) _set_progress(0.95) - shutil.rmtree(export_root, ignore_errors=True) + # 非 LeRobot 分支产出的只是一个选集的 zip,没有数据集目录可留。 + shutil.rmtree(export_root, ignore_errors=True) task = _load_task(job_id) if task: task["status"] = "completed" @@ -788,7 +829,9 @@ _RE_TASKS: dict[str, dict] = {} _RE_TASKS_DIR = STATE_ROOT / "re_export_tasks" _PASSTHROUGH_TYPES = { - "annotation", "human_review", "ai_annotation", "ai_quality_review", + # data_quality 由 ai_quality_review + data_cleaning 合并而来;此处的 t 已过 + # canonical_node_type(),旧 slug 不会出现。 + "annotation", "human_review", "ai_annotation", "data_quality", } _CAMERA_TYPES = {"rgb_camera", "fisheye_camera", "rgbd_camera", "stereo_camera", "stereo_rgbd_camera", "mono_camera"} diff --git a/processor/app/routes/pages.py b/processor/app/routes/pages.py index 26766a8019c797ceac1d0f085767e59000f0e2ad..804f93cc737e97a4abf40a0d65dd3b8d43b078ba 100644 --- a/processor/app/routes/pages.py +++ b/processor/app/routes/pages.py @@ -21,10 +21,30 @@ _static_dir = STATIC_DIR # 静态资源缓存键的唯一来源:模板里写 ``?v={{ asset_version }}``。 # 以前每个模板各写各的日期串,发版时靠人记得逐个改 —— 漏一个,那个页面 -# 就会继续发浏览器缓存里的旧脚本。现在只 bump app/version.py 一处。 -from app.version import __version__ as _asset_version - -templates.env.globals["asset_version"] = _asset_version +# 就会继续发浏览器缓存里的旧脚本。 +# +# ★ 取的是**前端号**(web/VERSION),不是后端号。改前端只 bump 那一个文件即可, +# 提交信息里的「前端 X」就是它 —— 缓存键和版本记录是同一个数,不会对不上。 +# (原来取后端号,于是每次改前端都要同时 bump 后端号和前端号两个地方。) +from app.web_version import frontend_version as _frontend_version + +templates.env.globals["asset_version"] = _frontend_version() + +# 服务端取词 —— 模板里写 ``{{ t('nav_home') }}``。语言由 app/i18n.py 的 +# LanguageMiddleware 从 cookie 读进 ContextVar,这里是同一个上下文,读得到。 +# 这样首屏渲染出来的就是正确语言,不再"先英文后由 JS 纠正"(那会闪一下)。 +from app.i18n import get_current_lang as _get_lang +from app.i18n import t as _t + +templates.env.globals["t"] = _t +# 模板要写 ,所以语言码也放进全局。 +# +# ★ 注册的是**函数**,模板里必须写成 ``{{ current_lang() }}`` 带括号。 +# Jinja 的全局是"每次渲染取一次这个对象" —— 值是固定的,而语言是**每请求** +# 才定的(存在 ContextVar 里),所以只能注册一个可调用的东西,渲染时调它。 +# 少写括号不会报错,只会把函数的 repr 渲染进 HTML: +# +templates.env.globals["current_lang"] = _get_lang @router.get("/", response_class=HTMLResponse) @@ -68,6 +88,16 @@ async def trash_page(request: Request): return templates.TemplateResponse("trash.html", {"request": request}) +@router.get("/train", response_class=HTMLResponse) +async def train_page(request: Request): + """Model training — read-only view of checkpoints produced outside the platform. + + Data comes from ``GET /api/v1/training/runs`` (see ``app/routes/training.py``). + Nothing on this page starts or stops training yet. + """ + return templates.TemplateResponse("train.html", {"request": request}) + + @router.get("/users", response_class=HTMLResponse) async def users_page(request: Request, _: dict = Depends(require_roles("admin"))): """Account management is visible only to administrators.""" diff --git a/processor/app/routes/training.py b/processor/app/routes/training.py new file mode 100644 index 0000000000000000000000000000000000000000..c999590ee4b76543c1acd9cc9e584d070317705b --- /dev/null +++ b/processor/app/routes/training.py @@ -0,0 +1,78 @@ +"""Training API —— 路由层,薄薄一层。 + +业务逻辑在 ``app/training/`` 下(产物扫描 / GPU 读取 / 数据集发现),这里只负责 +把它们挂到 URL 上。**没有任何训练执行** —— 见那个包的 docstring。 + +将来接执行时,新端点加在这里(创建 / 停止 / 日志),形状与现有的保持一致; +真正的子进程管理写在 ``app/training/runner.py``。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from app.training import ( + list_datasets, list_gpus, list_projects_progress, list_runs, +) +from app.training.runs import resolve_checkpoint_file + +router = APIRouter(prefix="/api/v1/training", tags=["training"]) + + +@router.get("/gpus") +def gpus(): + """本机 GPU(读 ``nvidia-smi``)。 + + **驱动不可用时靠 ``available: false`` 区分**,不要把它当成"利用率 0%" —— + 详见 ``app/training/gpus.py``。 + """ + return list_gpus() + + +@router.get("/datasets") +def datasets(): + """可训练的数据集 —— 审核页导出后落在 ``state/datasets/`` 的成品。 + + 空列表是**正常**的(还没导出过),前端据此渲染引导而不是报错。 + """ + return list_datasets() + + +@router.get("/runs") +def runs(): + """训练产物(checkpoints)。目录不存在时返回空列表,不是 404。""" + return list_runs() + + +@router.get("/ready") +def ready(): + """各项目离「可以导出」还有多远 —— 训练页「到点提醒」用。 + + 判据:**已通过集数达到项目设置的采集上限**(``params.target_episodes``)。 + 到点之后只**提醒**,不自动导出 —— 理由见 ``app/training/projects.py``。 + """ + return list_projects_progress() + + +@router.get("/runs/{run_name}/checkpoints/{step}/files/{name:path}") +def download_checkpoint_file(run_name: str, step: str, name: str): + """下载 checkpoint 里的单个文件。 + + ``{name:path}`` 而不是 ``{name}``:要下的文件在子目录里 + (``pretrained_model/model.safetensors``),默认的路径参数不匹配斜杠。 + + **安全性由 ``resolve_checkpoint_file`` 保证** —— 它只在扫描出的真实文件清单里 + 按名精确匹配,不做路径拼接,所以构造不出清单外的路径(比如 ``../../etc/passwd``)。 + 详见那个函数的 docstring。 + + 单个文件而不是打包 zip:一个 checkpoint 约 600MB,其中 ``training_state/`` + (优化器状态,约 400MB)**只在续训时有用**,拿去推理/部署只需要 + ``pretrained_model/``(约 200MB)。让用户按需下载,比强制他拉一个 600MB 的包好。 + """ + path = resolve_checkpoint_file(run_name, step, name) + if path is None or not path.is_file(): + raise HTTPException(status_code=404, detail="No such checkpoint file") + # filename 只取最后一段 —— 让浏览器存成 model.safetensors 而不是把整条相对路径 + # 当成文件名(带斜杠的文件名在多数系统上写不出来)。 + return FileResponse(path, filename=path.name) diff --git a/processor/app/training/__init__.py b/processor/app/training/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ea9d69f9d034e3937d887f85776cf8ffd508304b --- /dev/null +++ b/processor/app/training/__init__.py @@ -0,0 +1,24 @@ +"""训练域 —— 产物发现、GPU 读取,以及将来的训练执行。 + +**目前不含任何训练执行。** 下面三个模块都只是**只读扫描**: + + gpus.py 本机 GPU(nvidia-smi) + datasets.py state/datasets/ 下的可训练数据集 + runs.py settings.train_root 下的训练产物(checkpoints) + +将来接执行时,子进程管理、任务状态、日志解析落在 ``runner.py``;路由仍旧留在 +``app/routes/training.py``(与 ``app/ai_annotation.py`` + ``routes/annotations.py`` +的分工一致:域逻辑在域包里,HTTP 那层很薄)。 + +**为什么单独开一个包而不是继续放 ``routes/training.py``**:和前端 +``static/js/train/`` 对称,让训练功能一开始就有个像样的家。反例就在旁边 —— +``app/ai_annotation.py`` 长到 180KB 才想起来该拆。新功能先模块化,比事后从 +大文件里往外抠容易得多。 +""" + +from app.training.datasets import DATASETS_DIR, list_datasets +from app.training.gpus import list_gpus +from app.training.projects import list_projects_progress +from app.training.runs import list_runs + +__all__ = ["DATASETS_DIR", "list_datasets", "list_gpus", "list_projects_progress", "list_runs"] diff --git a/processor/app/training/datasets.py b/processor/app/training/datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..fb64b9de9ba00b200de622215a7c1510887add2b --- /dev/null +++ b/processor/app/training/datasets.py @@ -0,0 +1,71 @@ +"""可训练数据集的发现 —— ``state/datasets/<名字>``。 + +这些是**在视频审核页选中「已通过」的集、导出后留下来的成品**(落盘见 +``app/routes/export.py: _persist_trainable_dataset``)。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from app.localstore import STATE_ROOT + +# 与 `app/routes/export.py` 的 DATASETS_DIR 是同一个目录。从 STATE_ROOT 推出来 +# 而不是 import 那个模块:写进去的和读出来的必须指向同一处,而"唯一真源"本来 +# 就是 STATE_ROOT。两边若哪天分叉,导出落下的数据集训练页就会看不见 —— +# 改其中一处时记得对照另一处。 +DATASETS_DIR = STATE_ROOT / "datasets" + + +def list_datasets() -> dict: + """``{"datasets": [...], "root": str, "exists": bool}``。 + + **不列 ``sessions/<项目>`` 原始项目。** 原始项目里混着未审核的集,而且 + 留出集(UMIGripper_Action_AI 的 52–73)就在里面 —— 按项目选等于同时踩两个 + 坑,UI 上只能报警拦不住。导出那一步才是唯一能明确控制"到底训了哪些集"的 + 地方,所以以导出产物为准。 + + **也不列 ``state/exports/*``。** 那边是按 run_id 命名的**处理产物**,实测 + 168 条里每条 ``total_episodes`` 都是 1(单集),凑不成训练集。 + """ + root = DATASETS_DIR + if not root.is_dir(): + return {"datasets": [], "root": str(root), "exists": False} + + datasets: list[dict] = [] + try: + for entry in sorted(root.iterdir()): + # 跳过 .staging / .old 这类交换中间态 —— 它们只在替换过程中短暂出现。 + if not entry.is_dir() or entry.name.startswith("."): + continue + info_path = entry / "meta" / "info.json" + if not info_path.is_file(): + continue + try: + info = json.loads(info_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if not isinstance(info, dict): + continue + episodes = int(info.get("total_episodes") or 0) + if episodes <= 0: + continue + try: + mtime = int(info_path.stat().st_mtime) + except OSError: + mtime = 0 + datasets.append({ + "name": entry.name, + "path": str(entry), + "total_episodes": episodes, + "total_frames": int(info.get("total_frames") or 0), + "fps": float(info.get("fps") or 0), + "codebase_version": str(info.get("codebase_version") or ""), + "mtime": mtime, + }) + except OSError: + return {"datasets": [], "root": str(root), "exists": True} + + datasets.sort(key=lambda d: (-d["total_episodes"], d["name"])) + return {"datasets": datasets, "root": str(root), "exists": True} diff --git a/processor/app/training/gpus.py b/processor/app/training/gpus.py new file mode 100644 index 0000000000000000000000000000000000000000..21e20ff3abe758f248e4d8f9be03049106682fc6 --- /dev/null +++ b/processor/app/training/gpus.py @@ -0,0 +1,67 @@ +"""本机 GPU 读取 —— 直接问 ``nvidia-smi``。 + +这一层**不认识 HTTP**:它返回普通 dict,由 ``app/routes/training.py`` 直接 +交给 FastAPI 序列化。所以它可以被将来任何地方复用(比如训练 runner 起子进程 +前先确认显卡可用),而不必绕道走一遍路由。 +""" + +from __future__ import annotations + +import shutil +import subprocess + +# nvidia-smi 的查询字段 —— 顺序即解析顺序,改这里必须同步改 _parse 的索引。 +_QUERY = "index,name,memory.used,memory.total,utilization.gpu,temperature.gpu" + + +def _parse(stdout: str) -> list[dict]: + gpus: list[dict] = [] + for line in stdout.strip().splitlines(): + parts = [p.strip() for p in line.split(",")] + if len(parts) < 6: + continue + try: + gpus.append({ + "index": int(parts[0]), + "name": parts[1], + "memory_used_mb": int(float(parts[2])), + "memory_total_mb": int(float(parts[3])), + "utilization": int(float(parts[4])), + "temperature": int(float(parts[5])), + }) + except ValueError: + continue # 单卡读不出来不该让整张列表失败 + return gpus + + +def list_gpus() -> dict: + """``{"available": bool, "error": str, "gpus": [...]}``。 + + **驱动没起来时如实报错,不要返回一排 0。** 实测这台机器上 ``nvidia-smi`` + 会以 "couldn't communicate with the NVIDIA driver" 失败;若把它当成 + "利用率 0%、显存 0" 渲染出去,看起来是"显卡闲着可以训",而真相是"根本 + 问不到显卡"。前端据此显示明确的错误条 —— `available` 这个字段存在的唯一 + 理由就是让调用方**必须**区分这两种情况。 + """ + nvidia_smi = shutil.which("nvidia-smi") + if not nvidia_smi: + return {"available": False, "error": "nvidia-smi not found", "gpus": []} + + try: + proc = subprocess.run( + [nvidia_smi, f"--query-gpu={_QUERY}", "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=10, check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + return {"available": False, "error": str(exc), "gpus": []} + + if proc.returncode != 0: + # 驱动没起来走的就是这条 —— nvidia-smi 把原因写在 stderr。 + lines = (proc.stderr or proc.stdout or "").strip().splitlines() + return { + "available": False, + "error": lines[0] if lines else f"nvidia-smi exited {proc.returncode}", + "gpus": [], + } + + return {"available": True, "error": "", "gpus": _parse(proc.stdout)} diff --git a/processor/app/training/projects.py b/processor/app/training/projects.py new file mode 100644 index 0000000000000000000000000000000000000000..7851b19f482084322407f52feb5cd2cd83f567b8 --- /dev/null +++ b/processor/app/training/projects.py @@ -0,0 +1,83 @@ +"""项目离「可以导出」还有多远 —— 训练页「到点提醒」的数据源。 + +判据由用户定:**已通过集数达到项目设置的采集上限**(``params.target_episodes``) +就算「到点」。到点之后训练页提醒一下,由人去点导出,而不是自动跑 8 小时。 + +★ 为什么不做全自动:导出一次约 **30 秒/集**(见 UI 的 batch_export_hint), +一个 1000 集的项目要跑 8 小时以上,而且构建好的数据集会被"固定"下来 —— +已经拿去训练的那份不能悄悄变。让机器跑 8 小时 vs 让人点一下,后者换到的是 +"我知道现在在导什么、用哪份数据"。 +""" + +from __future__ import annotations + +from app.localstore import list_projects, scan_sessions + +# 与项目「已通过」口径一致的状态。与 export.py 的导出前置校验用的是同一组 +# (那边是 ("reviewed", "approved")),不要各写一套。 +_APPROVED = ("reviewed", "approved") + + +def _count_by_project() -> tuple[dict[str, int], dict[str, int]]: + """``({项目名: 已上传集数}, {项目名: 已通过集数})``。 + + **只扫一次。** ``scan_sessions()`` 是全量遍历,数据挂在 NAS/sshfs 上时很贵 + (见 README 里"打开 episode 慢"那一条);按项目各扫一遍就是 O(项目数) 次全量。 + """ + uploaded: dict[str, int] = {} + approved: dict[str, int] = {} + try: + episodes = scan_sessions() + except Exception: + return uploaded, approved # 存储暂时不可用时不该让整页炸掉 + for episode in episodes: + name = episode.get("project") + if not name: + continue + uploaded[name] = uploaded.get(name, 0) + 1 + if episode.get("status") in _APPROVED: + approved[name] = approved.get(name, 0) + 1 + return uploaded, approved + + +def list_projects_progress() -> dict: + """``{"projects": [...], "ready_count": int}``,按接近上限的程度排序。 + + 每个项目带 ``target`` / ``uploaded`` / ``approved`` / ``ready``: + + target 项目设置的采集上限(params.target_episodes);没设就是 0 + uploaded 已上传的集数(不分审核状态) + approved 已通过(``reviewed``/``approved``)的集数 + ready approved >= target,且 target > 0 + + **target 为 0 的项目永远不算 ready** —— 没设上限就没有"到点"这回事, + 不能把"没配"当成"已达"。 + """ + uploaded, approved = _count_by_project() + + projects: list[dict] = [] + for project in list_projects(): + name = str(project.get("name") or "") + if not name: + continue + target = int((project.get("params") or {}).get("target_episodes") or 0) + done = approved.get(name, 0) + projects.append({ + "name": name, + "target": target, + "uploaded": uploaded.get(name, 0), + "approved": done, + # target <= 0 ⇒ 没配上限 ⇒ 永不 ready(见 docstring) + "ready": target > 0 and done >= target, + }) + + # 已达上限的排最前,其次按完成度高的排 —— 页面从上往下读就是"先看这个"。 + def sort_key(item: dict) -> tuple: + ratio = (item["approved"] / item["target"]) if item["target"] else -1.0 + return (not item["ready"], -ratio, -item["approved"]) + + projects.sort(key=sort_key) + return { + "projects": projects, + "ready_count": sum(1 for p in projects if p["ready"]), + } diff --git a/processor/app/training/runs.py b/processor/app/training/runs.py new file mode 100644 index 0000000000000000000000000000000000000000..550b02a5564e5590a34762471875cb3689a21ea7 --- /dev/null +++ b/processor/app/training/runs.py @@ -0,0 +1,175 @@ +"""训练产物的发现 —— ``settings.train_root`` 下的 ``checkpoints//``。 + +模型由官方 ``lerobot-train`` 在平台**之外**训练,产物就是一棵目录树。这一层 +只负责如实把它读出来,不判断好坏、不执行任何训练。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from app.config import settings + + +def _step_of(name: str) -> int | None: + """``checkpoints`` 下的目录名 ⇒ 步数。 + + 正常的 checkpoint 目录就是零填充的步数(``005000``)。``last/`` 之类的 + 非数字目录**跳过** —— 它通常是个空的符号链接残留,列出来只会让人以为 + 多了一个模型。无法解析一律返回 None,调用方据此过滤。 + """ + try: + return int(str(name).strip()) + except (TypeError, ValueError): + return None + + +def _collect_files(root: Path) -> tuple[int, list[dict]]: + """递归列出一个 checkpoint 的全部文件与总字节数。 + + 用 ``rglob`` 遍历真实磁盘内容而不是写死 ``pretrained_model/`` 与 + ``training_state/`` 两棵子树 —— 不同 LeRobot 版本落盘的文件集不一样 + (我们比参考实现少 ``scheduler_state.json``、多四个 pre/postprocessor), + 写死会在版本升级时静默漏列文件。 + """ + files: list[dict] = [] + total = 0 + for path in sorted(root.rglob("*")): + try: + if not path.is_file(): + continue + st = path.stat() + except OSError: + continue # 竞态:训练正在写/文件刚被删 + files.append({ + "name": str(path.relative_to(root)), + "size": st.st_size, + "mtime": int(st.st_mtime), + }) + total += st.st_size + return total, files + + +def _read_train_config(checkpoint: Path) -> dict: + """读 checkpoint 的 ``train_config.json``;缺失/损坏返回 {}。 + + 这份就是「训练参数」页签的数据源 —— 训练当时实际用的超参,而不是我们 + 事后猜的默认值。 + """ + path = checkpoint / "pretrained_model" / "train_config.json" + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return doc if isinstance(doc, dict) else {} + + +def _scan_run(run_dir: Path) -> dict | None: + """把一个 ``output_dir`` 扫成一个 run;没有 ``checkpoints/`` 就返回 None。""" + checkpoint_root = run_dir / "checkpoints" + if not checkpoint_root.is_dir(): + return None + + checkpoints: list[dict] = [] + for entry in sorted(checkpoint_root.iterdir()): + if not entry.is_dir(): + continue + step = _step_of(entry.name) + if step is None: + continue + total_bytes, files = _collect_files(entry) + if not files: + continue # 空目录(如 last/ 的残留)不成其为模型 + checkpoints.append({ + "step": step, + "name": entry.name, + "file_count": len(files), + "total_bytes": total_bytes, + "files": files, + # 参数按**该 checkpoint**自己的配置读,不跨 checkpoint 复用 —— + # 同一个 output_dir 下不同步数的超参可以不同(改过参数续训)。 + "train_config": _read_train_config(entry), + }) + + if not checkpoints: + return None + # 最新的排最前 —— 页面按这个顺序展示,「训练参数」页签也取第一份。 + checkpoints.sort(key=lambda item: item["step"], reverse=True) + newest = checkpoints[0] + policy = newest["train_config"].get("policy") or {} + return { + "name": run_dir.name, + "path": str(run_dir), + "model_type": str(policy.get("type") or ""), + "checkpoint_count": len(checkpoints), + "total_bytes": sum(c["total_bytes"] for c in checkpoints), + "checkpoints": checkpoints, + } + + +def resolve_checkpoint_file(run_name: str, step: str, relative_name: str) -> Path | None: + """把 (run 名, checkpoint 名, 相对文件名) 解析成一个可下载的**真实路径**。 + + 找不到返回 None,由路由层转成 404。 + + ★ **安全:不做路径拼接。** 直觉写法是 `checkpoint_dir / relative_name`,但那样 + ``../../../../etc/passwd`` 就能读到任意文件 —— 这个端点是要对外开下载的, + 必须挡住。这里改成:**先扫一遍拿到磁盘上的真实文件清单,再按名字精确匹配**, + 所以返回的路径必然来自真实条目,构造不出清单外的路径。 + + (不用 `Path.resolve()` + `is_relative_to` 那种断言式校验 —— 清单匹配是从 + 源头上就没有越界的可能,而不是"先拼出来再检查拼对了没"。) + """ + wanted = str(relative_name or "").strip() + if not wanted: + return None + + data = list_runs() + for run in data.get("runs", []): + if run.get("name") != run_name: + continue + for checkpoint in run.get("checkpoints", []): + if checkpoint.get("name") != step: + continue + for entry in checkpoint.get("files", []): + if entry.get("name") == wanted: + root = Path(run["path"]) / "checkpoints" / step + return root / wanted + return None + + +def list_runs() -> dict: + """``{"runs": [...], "root": str, "exists": bool}``。 + + **两种层级都支持**,因为两种都很常见、也都会被人配进来: + + root 本身就是 run /checkpoints//… + root 是 run 的父目录 //checkpoints//… + + 实测默认路径 ``文件测试/umi_act_model`` 属于前者 —— 把它按后者扫会 + 一律返回空列表(``umi_act_model/checkpoints/checkpoints`` 当然不存在)。 + + 目录不存在 ⇒ ``{"runs": []}``(不是错误):新机器、或本机从未跑过训练, + 都是正常情况,前端据此渲染空态而不是把"没有模型"显示成"出错了"。 + """ + root = settings.train_root + if not root.is_dir(): + return {"runs": [], "root": str(root), "exists": False} + + runs: list[dict] = [] + try: + own = _scan_run(root) + if own is not None: + runs.append(own) + else: + for entry in sorted(root.iterdir()): + if not entry.is_dir(): + continue + run = _scan_run(entry) + if run is not None: + runs.append(run) + except OSError: + return {"runs": [], "root": str(root), "exists": True} + + return {"runs": runs, "root": str(root), "exists": True} diff --git a/processor/app/umi_slam_action.py b/processor/app/umi_slam_action.py index 3a362bad20414dc83984b70182053571a4ad4241..51bb5c068c4cbbff9a432a4a2f95047218c64fec 100644 --- a/processor/app/umi_slam_action.py +++ b/processor/app/umi_slam_action.py @@ -73,7 +73,8 @@ def matrix_to_rotvec(rotation: np.ndarray) -> np.ndarray: def poses_from_trajectory(trajectories, timestamps, - frame_count: int) -> np.ndarray | None: + frame_count: int, + diagnostics: dict | None = None) -> np.ndarray | None: """从 ``observation.slam_trajectory`` 的样本缓冲重建逐帧位姿 ``(N, 7)``。 slam_trajectory 是采集端的高频(50Hz)样本缓冲,每格存 @@ -90,6 +91,18 @@ def poses_from_trajectory(trajectories, timestamps, 的锚点做最小二乘拟合,不依赖外部时钟。 返回 ``(frame_count, 7)``;样本不足返回 ``None``。 + + 传入 ``diagnostics`` 时会把覆盖率统计写进去(就地更新,不改返回值): + + {"frames": N, # 总帧数 + "direct": n, # 有真实样本直接覆盖的帧 + "interpolated": n, # 只能插值兜底的帧 ← ★ 真正该关心的数字 + "empty_slots": n} # 自己格子里没有样本的帧(含被相邻格子救回的) + + ★ 注意 ``empty_slots`` 不是问题指标 —— 位姿流是 20/20/60ms 突发、数据帧是 + 1/30s 均匀网格,自己格子为空是**设计使然**(实测占 ~28%),其中 97% 能被 + 相邻格子覆盖。真正该关心的是 ``interpolated``:既没有本地样本、邻近格子 + 也没有,只能靠插值硬补 —— 那才是 SLAM 跟丢的证据。 """ import numpy as np @@ -110,13 +123,24 @@ def poses_from_trajectory(trajectories, timestamps, groups = values.size // 8 if groups <= 0: continue + last_sample_t: float | None = None for g in range(groups): chunk = values[g * 8:g * 8 + 8] if not np.isfinite(chunk).all(): continue - samples.append((float(chunk[0]), [float(v) for v in chunk[1:8]])) - if has_anchor[index]: - anchor_slam.append(float(values[(groups - 1) * 8])) + last_sample_t = float(chunk[0]) + samples.append((last_sample_t, [float(v) for v in chunk[1:8]])) + # 锚点取【最后一个有效组】的 t,而不是盲取最后一组。 + # + # 导出产物会把 slam_trajectory 补齐到定长(如 128 = 16 组),补的是 NaN + # (见 lerobot_export._pad_sparse_matrix_columns)。盲取最后一组会把 + # NaN 塞进下面的 polyfit 方程 → SVD 不收敛 → LinAlgError,整集派生失败。 + # + # canonical 数据(采集端直接写的变长列表)不含 NaN,所以生产路径碰不到; + # 但任何"读导出数据集再派生"的场景(质检、re-export、数据回灌)都会踩。 + # 上面的循环已经跳过 NaN 组了,这里复用同一个判定即可。 + if has_anchor[index] and last_sample_t is not None: + anchor_slam.append(last_sample_t) anchor_data.append(float(stamp[index])) if len(samples) < 2 or len(anchor_slam) < 2: return None @@ -153,6 +177,16 @@ def poses_from_trajectory(trajectories, timestamps, # 4) 真实样本覆盖不到的少数帧(实测约 3%)才插值兜底 valid = np.where(~missing)[0] + if diagnostics is not None and frame_count > 0: + # 注意用 missing.sum() 而不是 len(missing) —— 后者是数组长度(恒等于 + # frame_count),不是 True 的个数。两者相加必须等于 frames。 + diagnostics.update({ + "frames": int(frame_count), + "direct": int(len(valid)), # 有真实样本直接覆盖 + "interpolated": int(missing.sum()), # 只能插值兜底 + # 自己格子里没样本的帧数 —— 由调用方按需统计,这里只报总数 + "empty_slots": int(diagnostics.get("empty_slots", 0)), + }) if len(valid) < 2: return None for index in np.where(missing)[0]: diff --git a/processor/app/version.py b/processor/app/version.py index 75e90ae8a60aa4afaa3e60d3c9513ecd834be711..2838e61c399db2e2888f21b7138b661af411344e 100644 --- a/processor/app/version.py +++ b/processor/app/version.py @@ -6,12 +6,13 @@ - 新增功能 / 接口变化 → 次版本 +1 (0.1.x → 0.2.0) - 不兼容的大改动 → 主版本 +1 (x.0.0) -这个号同时被用作**静态资源的缓存键**(见 app/routes/pages.py 注入的 -``asset_version``):改了 JS/CSS 只要 bump 这里,所有模板的 ``?v=`` 一起失效。 -不要再逐个模板手写日期串 —— 漏掉一个就会给用户继续发旧脚本 -(本次就漏过 exports.html,它加载的还是 202609032200 版 app.js)。 +**这个号只管后端。** 它**不再是**静态资源的缓存键 —— 从 1.19.4 起,模板里的 +``?v={{ asset_version }}`` 取自 **web/VERSION**(前端号,见 app/web_version.py)。 +改前端不用再动这里;改后端也不用(前端资源没变,不需要失效缓存)。 -前端版本独立管理, 见 web/workflow-studio/package.json 的 version 字段. +前端版本独立管理, 见 **web/VERSION** —— 它覆盖 processor/web/ 下的全部前端代码 +(templates/ + static/ + workflow-studio/),不只是那个 React SPA。 +`web/workflow-studio/package.json` 的 version 只代表 SPA 自己,两者不必同步。 """ # 1.8.0 审核页选择功能重构(项目级三态全选、批量栏置顶 + 快慢双路径、 # 15s 轮询不再整表重绘)+ 导出性能(力矩阵分位数向量化 15×) @@ -38,4 +39,877 @@ # (启动前 fusermount3 -u -z)、API/worker 改 Requires+After 挂载、 # Restart 一律 always;另加每分钟看门狗自动重挂/重启。纯运维配置, # 无代码行为与用户可见变化 -__version__ = "1.8.6" +# 1.8.7 图标离线化收尾。iconify-icon 对**不在** iconify-preload.js 白名单里的 +# 图标会去 api.iconify.design 拉,国内网络下表现为页面/面板卡住几秒。 +# 替换三处漏网图标:侧边栏 Users(team-outlined → user-outlined, +# 每个页面都渲染)、AI 标注遮罩(loading-3-quarters-outlined → +# loading-outlined)、以及新增的工作流清洗节点。同时把 +# scripts/check_icon_preload.sh 的扫描范围扩到 app/processing/modules/ +# —— 节点图标是在 Python 里声明的,此前漏扫,改了白名单外的图标 +# 检查脚本仍报「全部已预加载」 +# 1.9.0 数据质检子系统上线(后端 + 设置面板)。 +# 卡片合并:AI Quality Review + Data Cleaning → Data Quality(旧 slug 自动迁移, +# 端口 key 不变因此连线不受影响)。9 个检查项(视频 4 + UMI 5)按设备卡片 +# 声明归属,设置面板按连线出的设备分 tab 独立配置检查项与阈值。 +# 新 API:/api/v1/quality/{checks,summary,episodes/*}。报告只写 +# episode state 的 cleaning_report,**不碰 status 流转**(只记录不拦截)。 +# 阈值只有一处定义(检查项的 default_params),节点只存覆盖值 +# 1.9.1 质检**真正跑起来**。1.9.0 只把节点和面板立了起来,``run()`` 还是透传, +# 跑工作流并不会执行任何检查。接法:worker 的 run 完成回调调 +# ``cleaning.trigger.spawn_cleaning_for_run``(与 video_quality 门禁同一 +# 挂载点,放后台任务、走线程池)——**不在 ``run()`` 里跑**,因为 DAG 执行期 +# worker 在 staging 目录干活,那时读到的是上一轮数据(action 列会是采集端 +# 的零占位)。需要 ``episode_index``:``get_episode()['path']`` 是**项目 +# 目录**、多集共享,漏传会拿第 0 集的 parquet 比全部集的视频。 +# +# 同时修掉三处「检查项静默不跑」——都不报错、报告照常生成: +# (1) 配置白名单 bug:前端只在用户**碰过**的项上写 entry,引擎却把"显式 +# 启用的那些"当白名单,于是**改一个阈值 = 其余检查项全部停跑** +# (实测 9 项变 1 项)。配置只该是黑名单。 +# (2) 模态推断推不出 stereo_rgb/stereo_rgbd_camera("立体"不是列名能看见 +# 的特征),双目工作流在设置面板配的阈值查不到 tab、静默用默认值。 +# 改为「工作流连线的 hint ∪ 数据推断」。 +# (3) 状态覆盖竞态:``read/write_episode_state`` 各自加锁,RMW 跨两步不是 +# 原子的;质检与 video_quality 门禁在同一个 run 后并发写同一份 state, +# 后写的会用旧快照把先写的冲掉(``status`` 被冲 = 失败批次看着像已通过)。 +# 新增 ``localstore.mutate_episode_state``(锁内 RMW),两条路都改用它。 +# 对照实验:12 线程并发下手写 RMW 存活 1/12 且抛 10 次 FileNotFoundError +# (``_write_json`` 的临时文件名是固定的、多人共用),锁内 RMW 存活 12/12。 +# 1.9.2 数据不对 → **卡在人工审核**。质检判 FAIL/ERROR 的批次推回 to_review +# (审核页的入口,复用既有状态机,不新建状态),WARN 只记录不拦。 +# +# 光推状态挡不住:自动批准散在 ai_annotation._set_ai_quality_state 与 +# video_quality._apply_video_quality_result 两处,它们只看**自己的**报告 +# —— 质检判 FAIL、视频判 PASS 时坏数据照样被放行。而且三条门禁是并发跑 +# 的(都在 run 完成回调里 create_task),谁先写没有保证。所以质检额外在 +# cleaning_summary.blocking 上留一个**粘性标记**,两条自动批准路径都先 +# 查它;标记由下一次质检整份覆盖,数据修好后自动解封。 +# +# 拦截只针对**自动**批准:人工批准不受影响(实测 verified)。 +# 另外不碰 deleted/rejected —— 质检不能把用户删掉或否决过的批次复活。 +# 顺带把 _set_ai_quality_state 也收进锁内 RMW(它此前是手写 read→write, +# 与修复前的视频门禁同一个竞态)。 +# 1.9.3 修采集层三处静默失效(为接入手套检查清路)。都不报错、报告照常生成: +# (1) **非首集读出空帧** —— canonical 是一集一文件,采集却取 +# ``parquets[0]`` 再按 ``episode_index`` 列筛行,于是第 0 个文件只装 +# 第 0 集、其余集全筛成空。报告退化成 empty_report(episode.status= +# ERROR),在 1.9.2 的"数据不对就拦"链路里表现为**除第 0 集外每一集都 +# 被卡在人工审核**。改成按布局解析:canonical 选对文件且不再筛行, +# 导出产物(一文件多集)才筛行。全量验证 22 集/16 个不同 episode_index +# 零空报告(含编号有空洞的 000007)。 +# (2) **触觉阵列被截断** —— _CHANNEL_WIDTH 没有 tactile 项,落到 3 的兜底 +# 值,16x16=256 个感应点被静默截成 3 个(检查项只看得到 1%)。改为 +# "不在表里的信道一律不截断",兜底成某个具体数字本身就是隐患。 +# (3) **observation.tactile.* 认不出** —— 触觉有两套命名:采集协议写 +# observation.*_glove,导出统一成 observation.tactile.*。只认前者, +# 于是只跑过导出的树上手套信道为空、手套检查整条静默跳过。加别名 +# 归一化;两套并存时保留协议名(同一份数据,登记两条流会让缺陷报两次)。 +# 顺带核对:同项目内 schema 不一致(实测 D435 第 3 集只有右手)采集层本就 +# 按"有什么收什么"处理,无需改;但**写手套检查项时不能假设双手都在**。 +# 1.9.4 手套检查项上线(5 项),设置面板的 Glove Sensor tab 不再是空的。 +# 阈值不是拍的 —— 全部从真实数据反推,过程写在 checks/glove/ 各文件里: +# (1) **16×16 空间快照,行主序展平**。证据分两条,强度不同: +# - 行主序+每行 16 个: 自相关 |v[i]-v[i+k]| 对 k=1..40 在 **k=16** +# 处有尖锐极小值(S80C 左 43.6,而 k=15 是 86、k=17 是 87), +# 四份数据全部如此 → i 与 i+16 是空间邻居 ⇒ 每行 16 个、行主序。 +# - 16×16 这个分解: 试了 6 种(16x16、8x32、32x8、4x64、64x4), +# 16x16 的相邻格平均差最小(187 vs 227+)。**但行主序与列主序的 +# 数值完全相同(互为转置,总和对称)—— 这个测试区分不了行列, +# 行列是上面那条自相关定的。** +# 快照 vs 滚动缓冲: 相邻帧做任何格数平移差异都**更大**(不平移 33.0 +# vs 平移 78+,随机帧对 57.6) → 是快照。接触区稳定是 11-13 高 x +# 3-7 宽(沿行索引长、沿列索引窄);把它读成"手指"是**推测**,没证实。 +# (2) **取值被固件门限:要么 0,要么 >= 500.02**,0 到 500 之间一个值都 +# 没有(实测最小非零 500.0234)。所以 0 的含义是"未接触"而不是 +# "力为零"。glove.gate_floor 就是盯这个门限的 —— 固件改版/scale +# 应用两次时,列还在、还是 256 维、数还在合理区间,只有值域整体位移。 +# (3) **设备更新约 7.5Hz**(30fps 录制下同一个值连续保持恰好 4 帧) → +# 受压时静止 3-4 帧是**正常**。阈值取 warn 40 / fail 300 帧,留足 +# 余量给将来更新率更低的设备。 +# (4) **不做逐点"死点"检查**。整集恒定的感应点占 30-86%,图案是规则 +# 条纹/块状(左右手不同、跨项目也不同),那是**手套的传感分区**与 +# 手指是否接触,不是坏点 —— 做死点会在每一集误报 136-161/256。 +# 跨集基线也救不了:某根手指在全部集里都没用过同样分不出来。 +# 顺带加 device_status 信道(字符串列)。**数值化会把字符串列变成全空 +# 元组**(vector_series 转 float 抛异常被吞),检查项拿到的是"没有数据" +# 看起来像通过 —— 所以 _column_series 对字符串先分流。 +# +# 设置面板的 tab 只显示**画布上连到本节点**的设备卡片(用户明确要求: +# 没连接的设备不显示)。注意与执行侧的口径差异: 检查跑不跑取决于批次数据 +# 里有哪些信道(engine 按 evidence.channels 筛),连线只决定"卡片有没有 +# 入边(质检跑不跑)"和"显示哪些 tab"。所以理论上存在"数据里有该类设备、 +# 但画布上没连到质检卡 —— 检查照跑、阈值却在面板里配不到"的情况。 +# 1.9.5 采集层认多相机 SLAM 命名。``observation.slam_*`` 是默认相机,双目/多把 +# 第二路存成 ``observation.<相机名>_slam_*``,而采集写死了两个不带前缀的 +# 列名**并且循环末尾 break** —— 于是(1)只有前缀命名的集**有 SLAM 数据却一个 +# SLAM 检查都不跑**,(2)双目的第二路从来没被检查过,两处都不报错。 +# 改为按相机分组解析(每个相机各一条流,组内 trajectory 优先),列名用正则 +# 匹配并排除两个近亲: ``observation.slam_trajectory_ns``(时间戳伴生列) 与 +# ``processing.umi_slam_action.data_4.action``(处理缓存,名字里恰好含 slam)。 +# 实测 Test94_000003 立刻暴露真问题: 第二路相机 44.3%(601/1358) 的帧靠插值 +# 兜底,之前完全不可见。 +# 顺带把 slam_continuity 的注释与实现对齐(注释写"默认判 WARN 只报告", +# 实现一直是分档的: 单次跳变 WARN、>=10 处 FAIL)。**只改注释不改行为**。 +# 1.9.6 工作流调色板"进入页面后要等一会才刷新"。节点注册表 registry 是**模块级 +# Map、不是 React state** —— 往里面塞节点不触发重渲染。而 App 挂载后才异步 +# 拉 /api/v1/workflows/modules 并 hydrate 一次,且 App() 自身既无 useState 也 +# 不订阅 store(它只渲染一次),NodePalette 也没被 memo —— 于是调色板停留在 +# BUILTIN 兜底清单上,要等**用户跟它交互**(打字搜索/点折叠/悬停出提示)才 +# 顺带刷新。「UMI Slam Action」这类不在 BUILTIN 里的节点因此迟迟不出现。 +# +# 接口本身不慢:实测一次页面加载里 /modules 与其他请求都在 1 秒内返回, +# 所以问题不在网络而在**不重渲染**。 +# +# 修法:registry 加版本号+订阅,NodePalette 用 useSyncExternalStore 订阅; +# hydrateNodeTypes 结束时通知一次(循环内逐条通知会让调色板重渲染 N 次)。 +# 纯前端改动,刷新即生效,不用重启后端。 +# 1.9.7 质检全链路文案英文化。工作台界面本就是英文的(Home / Projects / Reviewing / +# Approved …),只有新加的质检是中文 —— 现在对齐:14 个检查项的 label/ +# description、30 条 Finding.message、SEVERITY_LABELS、report 里的训练用途 +# 标签与 summary、前端弹窗与参数标签、以及质检的日志输出。 +# 代码注释与 docstring **保持中文**(与全仓一致),只改面向用户的字符串。 +# 测试里断言文案的 6 处一并同步。 +# 1.9.8 画布端口与模块命名(前端为主,后端只有显示名)。 +# (1) **模块显示名 Data Quality → Data Cleaning**(用户要求:它就是数据清洗功能)。 +# slug 仍是 data_quality —— 那是持久化契约,改它会让已保存工作流的 +# nodeType 对不上,要再迁一次。只改显示名,不动 slug。 +# (2) **Data Quality 的输入端口按入边动态生成**:每接一个设备多一个口,口名 +# 取上游那个端口的名字。此前所有线挤在同一个 data 口上,口名只能把几个 +# 上游拼起来("Glove Sensor Data, RGB Video"),越长越读不出来。 +# 端口 key = `dynamicInputKey(源节点, 源端口)`,稳定可重复;老图的边在 +# normalizeWorkflowGraphForEditor 里迁移(全库实测只影响 1 个工作流 5 条边)。 +# 往**任意已有的口**上拖也能新增连接 —— onConnect 会把 targetHandle 重写 +# 成新上游的 key。**只对 data_quality 这一张卡生效**(DYNAMIC_INPUT_TYPES), +# 其余节点的端口文字一律保持声明文案。 +# (3) **卡片高度改为按各自端口数算**。此前是画布统一算的(取注册表里静态端口 +# 的最大值)—— 对运行时才长出来的动态口无能为力,接第 5 个设备时被撑破。 +# 顺带删掉那条全局计算与 getMaxPortRows(已被完全覆盖)。 +# (4) 修三处「拿规范化后的类型去比旧 slug」的**死条目**(永远匹配不上): +# canonicalLabelTypes / controlledPorts / _migrateWorkflowGraph 的 +# result→reviewed 改写。前两者导致老图一直显示旧标签 "AI Quality Review" +# (不走 descriptor.label);第三处本就该与后端 workflow_types.py 的 +# {"human_review","data_quality"} 对齐。另删 CANONICAL_PORTS 与 BUILTIN 里 +# 两个同类的死条目(BUILTIN 那个会让卡片在水合完成前短暂显示旧名)。 +# +# 走过又撤掉的弯路(记录在此免得再走):曾把「输入 key 是 data 就显示上游 +# 端口名」应用到**所有**泛化端口节点,以及把设备卡片的数据种类(后端从 +# MODALITY_CHANNELS 派生)展开成标签 —— 两者都被否掉了:前者波及导出/标注卡, +# 后者字长且与上游卡片重复。 +# 1.10.0 用户菜单加「界面语言」切换(中/英)。i18n.js 早就写好了一整套 +# (225 个 key × 2 语言、t()/setLang()/localStorage),但**切换入口从来没接** +# —— updateLangToggle() 找的是 #lang-toggle,所有模板里都没有这个元素, +# 功能等于死的。现在在用户下拉里加了 English / 中文 两个选项。 +# +# 顺带修一个真 bug:setLang() 一直写 localStorage,但**没有任何地方读它** —— +# currentLang 写死 'en',每次刷新都退回英文,切换等于没切。DOMContentLoaded +# 现在先读回保存的语言(隐私模式禁 storage 时 try/catch 回落默认)。 +# +# ⚠️ 覆盖度仍然不全:9 个模板里只有 4 个有 data-i18n 标注(index 24 / tasks 13 / +# base 7 / trash 3),exports、login、overview、users **一处都没有**(users 约 +# 39 处可见文本)。工作流画布是独立 React 应用,完全不走这套。补齐是第二步。 +# 1.10.1 侧边栏与用户菜单补齐中英切换(1.10.0 只接了入口,导航项没标注)。 +# 补 6 处:Workflow / Users / Annotation 三个导航项,以及用户菜单里的 +# Profile / Change Password / Logout。侧边栏每个页面都渲染,感知最强。 +# EgoData(品牌名)与 English / 中文(语言自名)**刻意不翻** —— 语言选项 +# 写自己的文字,切到中文后英文项不该变成"英语",否则反而找不到。 +# +# ★ 菜单项的文字必须先包进 :applyTranslations 用的是 +# ``el.textContent = t(...)``,直接在 上挂 data-i18n 会把同级的 +# iconify-icon 一起冲掉(图标会消失)。 +# +# 登录页(login.html)**保持英文**,不进这套 —— 用户要求:登录后再切。 +# 剩余未标注:users(45) / overview(14) / index(13) / exports(10) / tasks(2) +# 模板文案,以及 JS 动态生成的约 50-70 处;工作流画布是独立 React 应用, +# 完全不覆盖。 +# 1.10.2 工作流画布接中英切换(第一段:语言通道 + 调色板 + 工具栏)。 +# 画布是**独立 React 应用**,不加载 web/static/js/i18n.js。两边靠两个约定打通: +# 1. 同一个 localStorage 键 `lang` —— 模板侧 setLang 写、React 侧读 +# 2. 自定义事件 `egodata:lang` —— 模板侧切换时派发,React 侧监听 +# ★ 不用 storage 事件:那个**只在别的标签页**触发;而切换按钮就在本页侧边栏。 +# 新增 src/i18n.ts:useT() 订阅语言,语言一变所有用到它的组件重渲染。 +# 本轮覆盖:调色板(Input/Review/Process/Export 四个分组 + 搜索框 + 底部提示)、 +# PipelineToolbar(New/Save/Save As/Export/Workflow list)、顶栏(Template/ +# 设为模板/取消模板/Del/Cancel/新建工作流/从模板开始 + 模板对话框说明)。 +# 缺词回落 key 本身(如 palette.search)而不是静默显示英文 —— 漏翻一眼看得见。 +# +# 未覆盖(后续):节点卡片名(后端 20 个模块 label)、节点端口名(registry.ts +# 约 38 处)、DeviceQualityModal 参数标签(26 处)、NodeSettingsModal(11 处)、 +# workflowStore 的 toast(10 处)。 +# 1.10.3 工作流画布中文化(第二段:节点卡片名 + 端口名 + 搜索)。 +# 新增两张对照表(都放在 src/i18n.ts,查不到回落到英文原文,不显示成空白): +# NODE_LABELS_ZH 20 个模块 slug → 中文卡名(后端 label 是英文,只做显示层对照) +# PORT_LABELS_ZH 端口名,key 用 ``*:端口key`` 表通用、``节点:端口key`` 表特例 +# ★ 端口必须按「节点+端口」查而不是只按端口 key:同一个 ``data`` 在导出卡上 +# 是"可导出数据"、质检卡上是"数据"、审核卡上是"待审核数据",只按 key 会串味。 +# 调色板搜索同时匹配英文原名与中文译名 —— 切到中文后输入"手套"要能找到 +# Glove Sensor。已实测覆盖:端口 42/42、模块 20/20,无遗漏。 +# (上一轮 1.10.2 只做了语言通道 + 调色板分组 + 工具栏。) +# 1.10.4 工作流画布中文化收尾(第三段)。补完 1.10.2/1.10.3 漏掉的: +# PipelineToolbar 的 Run 与"保存中"三元、App 顶栏的 Template/Save、模板与 +# 共享对话框、AI 标注设置弹窗(API 供应商/模型/Key/标注语言 + 四处测试结果 +# 提示)、质检弹窗的参数标签(23 个)与单位(frames→帧 / s→秒 / jumps→处)、 +# 工作流抽屉、连线删除、节点悬停提示、store 里的四条 toast。 +# store 不是 React 组件、不能调 hook —— 那里直接 translate(k, currentLang())。 +# +# ★ 用了两轮才补全的教训:**字符串替换会漏掉特定写法**。`>Save<` 匹配不到 +# `{isSaving ? '...' : 'Save'}` 这种三元,也匹配不到图标后面的裸文本 +# (` Template`)。第二轮专门扫了这两类才补齐。若将来还要 +# 批量改 JSX 文案,先扫这两种模式。 +# +# 未覆盖:节点卡片描述(后端 description,仅悬停提示);后端报告的结论文案 +# (Finding.message)仍是英文 —— 那部分要中文得在后端加语言参数。 +# 1.10.5 首页概览(Overview)中文化。词条**早就写好在字典里**(overview_title / +# stat_reviewing / retry / view_all …)但 overview.html **一处 data-i18n 都没标** +# —— 和侧边栏同一类问题:翻译写了,标记没做。补 13 处静态标记 + 补 9 个缺的 +# 词条(stat_to_review / stat_distribution / daily_uploads / last_30_days / +# donut_total / stat_processing / stat_received / cleaning_passed / cleaning_failed)。 +# +# 环形图、柱状图、状态徽标是 dashboard.js **现画**的,静态标注管不到: +# label/title 改成 t() 调用,并给 setLang 的重渲染列表补上 refreshDashboard +# —— 之前那份列表有 loadTasks/loadProjects/… 但没有它,切语言后首页图表 +# 不跟着变,要手动刷新才生效。 +# 1.10.6 用户管理页中文化。用户相关的词条此前**一个都没有**(只有 nav_users), +# users.html 也没有 data-i18n —— 整页 35 处文案 + users.js 里 16 处动态生成的 +# (表格行的 Extend/Enable/Disable 按钮、角色与状态徽标、下拉选项、确认框)。 +# 新增 56 个词条。角色/状态走 key 映射查表、查不到回落原始值 —— 后端加新角色 +# 时显示原始值而不是空白。 +# +# ★ 两处裸文本必须包 再挂 data-i18n:``Password `` +# 与 ``Disable blocks login temporarily...`` —— applyTranslations +# 用 textContent 赋值,直接挂在父元素上会把平级的兄弟元素(提示 span)冲掉。 +# +# 同时给 setLang 的重渲染列表补 loadUsers(与 1.10.5 的 refreshDashboard 同一 +# 类缺口:表格行是 JS 现画的,不重跑就不跟着切语言)。 +# 已核对:users.html 用到的 33 个 data-i18n 键,中英两个字典都齐。 +# 1.10.7 模板层中文化收尾:index(12) / exports(10) / tasks(2) 三页补标注, +# 新增 23 个键。**模板层到此 9 个页面全部标注完毕**(login 按要求保持英文)。 +# 核对:各模板共引用 123 处 data-i18n 键,中英两个字典都齐。 +# +# ★ 顺带修掉字典里**4 个重复键**(all_status / save_changes / slice_conflict / +# target_episodes 在每个字典各定义了两次)。后一份静默覆盖前一份,其中 +# slice_conflict 与 target_episodes 两份**值不同** —— 前一份是死代码。 +# 去重时我一开始保留了第一份(错的),**那会改变实际显示文案**;已改回生效值。 +# 教训:去重"保留哪一份"必须按 JS 语义(后者覆盖前者)来,不能想当然。 +# +# 另修一处会回退的文案:SORT_MODES 的 label 存的是英文原文,而 +# toggleSortOrder() 会把它直接写进 DOM —— 切中文后点一次排序就变回英文。 +# 改成存 i18n key、写入时翻译。 +# +# 剩下:JS 层动态文案(player / slice-preview / annotations / tasks 等约 50 处)。 +# 1.10.8 修中英**不匹配**(不是"没翻译",是两个方向对不上)。审计结果: +# ① 键集合完全对齐(各 310 个,无单边键) +# ② 中英**值相同**的 8 个里,4 个是真漏翻:overview_title / cameras / +# left_hand / right_hand(中文界面一直显示英文)。另外 4 个是品牌名 +# processor / 死键 episodes / 术语 Port / 单位 s —— 保持英文正确。 +# ③ **反方向**:代码里写死中文、英文界面会露出来,9 处。已抽成词条: +# annotations 的四个状态(切段中/VLM 分析中/写入/写入数据集)、player 的 +# 「力 mN」与力场提示、slice-preview 的候选提示、index 的 AI 模式说明。 +# (base.html 的「中文」是语言自名,不翻是对的;index 的「只读」已有 +# data-i18n,只是初始 HTML 写中文,会被覆盖。) +# +# 顺带补一个缺口:applyTranslations **只处理 data-i18n 与 data-i18n-placeholder, +# 不处理 title** —— 带提示的按钮/图标没法靠标记翻译,只能写死。加了 +# data-i18n-title。 +# 1.10.9 修 1.10.6 引入的显示 bug:用户表的「操作」列渲染成字面量 +# ``+ t('users_edit') +`` 而不是按钮。 +# +# ★ 根因是**拼接语法用错了上下文**:那段 HTML 在**反引号模板字面量**里, +# 而我在里面写了单引号拼接 ``title="' + t('x') + '"`` —— 在反引号里 +# ``'`` 和 ``+`` 就是普通字符,原样显示。模板字面量里必须用 ``${t('x')}``。 +# 反过来,**单引号串**里用 ``${t('x')}`` 也会原样显示。 +# +# 口诀:<反引号模板>用 ${t()};<单引号拼接>用 ' + t() + '。看外层容器是哪个。 +# 自查命令(两个方向都能抓): +# grep -n "' \+ t(" web/static/js/*.js # 反引号里的误用 +# grep -n '\${t(' web/static/js/*.js # 逐个确认外层是反引号 +# +# 共修 10 处(users.js 的 Extend/Enable/Disable/Edit/Delete 五个按钮的 +# 文字与 title)。已核对:反引号模板里无残留错误拼接,单引号串里的拼接合法。 +# 1.11.0 修「回收站清不掉」。 +# +# 现象:点「清空回收站」没反应,也不报错。后端日志里是 +# ``POST /api/v1/episodes/purge-trash → 500``。 +# +# 根因是一个 9-08 留下的备份文件:两个 D435 项目的 ``meta/`` 里各有 +# ``info.json.bak-20260908-144550``,而 ``verify_project_dataset`` 的 meta +# 白名单是硬编码的 ``{info.json, stats.json, tasks.json, episodes}``,多出 +# 任何非点开头的文件都判 unexpected。而 ``delete_episode`` 是**先删文件、 +# 后跑校验**,校验失败就抛 RuntimeError;「清空」又是循环处理的,一个项目 +# 失败整轮中止 → 500。**一个备份文件让清空彻底不可用。** +# +# 修两处: +# (1) 校验放过备份/临时文件(``.bak`` / ``.bak-*`` / ``.orig`` / ``.tmp`` / +# ``.save`` / ``~`` / 隐藏文件)。实测四个项目现在全 passed,而 +# ``junk.txt`` / ``real_data.csv`` 这类真杂项仍然被抓 —— 不是放水。 +# (2) 前端 ``restoreEpisode`` / ``permanentDeleteEpisode`` / ``purgeTrash`` +# **只有 ``if (res.ok)`` 一个分支**,非 2xx 时什么都不做 —— 这就是 +# "点了没反应、也无从查起"的原因。补 else 分支,用新增的 +# ``apiErrorDetail()`` 把后端 ``detail`` 显示出来(取不到就退状态码, +# **绝不返回空**)。 +# +# ⚠️ 遗留隐患(本次未改):删除是**先删文件后校验**,校验因真实原因失败时 +# 文件已经没了、记录还在。实测 ``D435---black_glove_3d_keypoints_AI`` 的 +# ``episode_000001`` 就是这么没的(索引本身是对的,空洞合法)。 +# 1.11.1 修「回收站删不掉」的**真正**根因 —— 项目索引缓存未失效。 +# +# 1.11.0 修掉的那个 .bak 只是**第一个**拦路的,放行之后暴露出真问题: +# ``write_project_episode_index`` 是索引的唯一写入点,却没有失效 +# ``_PROJECT_CACHE``(2026-09-11 commit 2bb9fe 引入缓存时漏的)。于是同一次 +# 调用里"写完再读"拿到的是删除前的旧行: +# +# delete_project_episode: +# 读一次(预热 60s TTL 缓存) → 删文件 → 写索引 → verify_project_dataset +# → 又调 project_episode_rows → **旧缓存** → 报 "missing data: ..." → 500 +# +# 盘上的索引其实一直是对的,纯粹是缓存没跟上。delete_episode 末尾确实会 +# invalidate_session_cache(),但那要等异常之后 —— 永远走不到。 +# **删除功能自 9-11 起就是坏的。** +# +# 修在唯一的写入点,5 个调用方(delete / merge / repack ×2 / …)一并受益。 +# 实测:副本上连续删 7 个批次全部成功(5 个留行、2 个清空),校验零失败。 +# +# ★ 教训:加缓存时,**必须同时审所有写方**。这次是"读方加了缓存、写方忘了 +# 失效",症状还是那个老毛病 —— 不报错、报的是**别的**错误("文件缺失"), +# 把人往数据损坏的方向引,而真相在缓存里。 +# 1.11.2 修回收站页的 JS 报错:``TypeError: Cannot set properties of null +# (setting 'textContent') at trash:248:55``。 +# +# trash.html 的内联脚本在设 ``nav-review`` / ``nav-trash`` 的 textContent, +# 但 base.html 里**从来没有这两个 id**(侧边栏用的是 +# ````,由 i18n.js 的 applyTranslations 处理)。 +# getElementById 返回 null → 每次 DOMContentLoaded 必抛。``git log -S`` 查过: +# 该 id 从未存在过,所以是**一直就坏**,不是本轮引入。 +# (app.js / i18n.js 里同样的取值都写了 ``if (x)`` 保护,只有 trash.html 没有。) +# +# 处理:删掉那段多余的导航文案逻辑,本页只保留页面标题;标题在 +# DOMContentLoaded 与 egodata:lang 事件上各更新一次。 +# 1.11.3 修双目自动配对把动态端口画重复的 bug(前端为主)。 +# +# 现象:Stereo RGB-D Camera 的 3 个输出,在 Data Cleaning 上画出【4 个输入口】, +# 其中 "Left RGB Video" 重复。用户实测截图发现。 +# +# 根因在 onConnect 的双目配对:``siblingConn = {...next, sourceHandle: 副目}`` +# —— 只换了源端口,**targetHandle 沿用了主端口的动态 key**。而 isDup() 恰好 +# 按 targetHandle 判重,于是用户再手动连"另一目"时判成"没连过",又加一条。 +# 端口 key 只由「源节点+源端口」算出,两条同源端口的边 → 同一个 key → 两个同名口。 +# +# 普通节点(targetHandle 恒为 "video")主副相同,所以这个 bug 一直没暴露 —— +# 是 1.9.8 的动态端口把它揭出来的。 +# +# 对照实验(真实 store 驱动,非静态检查): +# with bug : 连左目→右目 → 4 条边 / 2 个 key;再连深度 → 6 条边 / 3 个 key +# (配对边甚至把 depth 的目标口也抄走了) +# fixed : 2 → 2 → 3 条边,key 数恒等于边数 +# 同时给 useDynamicInputs 加了按 key 去重 —— 一个 key 只能有一个口, +# 渲染层兜底,防止历史遗留的重复边再画出重复端口。 +# 1.11.4 上传后的存储索引改为**沿用名字里的编号**。 +# +# 此前 append_project_episode 一律用 max(现有)+1,而公开 ID(批次名)是 +# 沿用上传包的 —— 两套编号各走各的,于是"名字 Test94_000005 / 索引 3" +# 这种对不上的情况出现。**实测 119 个带编号批次里 21 个中招**(18%)。 +# +# 危害在 _match_episode_row 的名字回退:按 id 匹配不到时会用 +# episode_index_from_name() 反推索引,从而撞上别的批次。模拟确认: +# 删「..._000002」时若它自己的行已缺失,会去删 index=2 那个**别的批次** +# 的数据和视频。 +# +# 现在优先采用名字里的编号(编号允许空洞,模块里已写明 gaps are valid); +# 仅两种情况回落 max+1:名字里没有编号,或该编号已被别的批次占用。 +# 副本实测:传 Anything_000005 → 索引 5;传 Anything_000002(撞号)→ 6; +# 传 NoNumberHere → 7。 +# +# 顺带修同一处的缓存缺口:append 是在 temp_root 上写完再 rename 换入的, +# 而 write_project_episode_index 失效的是 **temp_root** 的 key —— 换入后 +# project_root 的缓存仍是旧行。后果两条:(1) 紧跟的 +# verify_project_dataset(project_root) 读旧行,**校验在验旧树**(三次 +# append 全部"通过"而盘上早已是 [0,1,2,5,6,7]);(2) 缓存有 60 秒盲目 +# TTL,上传后前端最长 60 秒看不到新批次。换入后显式失效。 +# 1.12.0 新增「模型训练」页 —— 只读展示训练产物;新页面前端改用真 ES module。 +# +# 背景:模型由官方 lerobot-train 在本平台**之外**训练(conda 环境 +# lerobot052_base_piper),产物落在 文件测试/umi_act_model/checkpoints/。 +# 此前要了解训练结果只能自己去翻文件系统。 +# +# 本期**只做界面,不执行训练**: +# • 后端 app/routes/training.py —— 唯一端点 GET /api/v1/training/runs, +# 只读扫描 checkpoints/ 并读 train_config.json。目录不存在返回 +# runs:[](新机器从没训过是正常情况,不是错误)。 +# 两种层级都支持:root 本身是 run,或 root 是装多个 run 的父目录。 +# • 前端 web/static/js/train/ —— 真 ES module(import/export),不再往 +# app.js 里堆。六个文件:api / dom / format / params / checkpoints / +# main,main.js 是唯一入口,其余是它 import 的模块。 +# • 「训练指标」「实时日志」两个页签**故意不做** —— 训练在平台外执行, +# 没有 loss/lr/grad_norm 落盘,也没留日志。没有数据就不显示:摆一个 +# 空页签比不摆更让人以为功能坏了。将来接了训练执行再加回 TABS 数组。 +# +# ★ 顺带堵一个缓存陷阱:static_cache_headers 现在对 /static/js/ 下**无查询 +# 串**的 JS 发 no-cache。原因:ES module 的相对 import 解析出的 URL 不带 +# 查询串 —— 入口 script 写了 ?v=,`import './api.js'` 也永远是同一个地址, +# 给它任何正数 max-age,升级后都会出现「新 main.js + 旧 api.js」混用, +# 直接抛 TypeError。范围刻意收窄:带 ?v= 的老脚本仍走 86400 那支, +# /static/ 顶层的第三方库(plyr.js、iconify-*)也不在范围内。 +# 1.12.1 侧边栏「模型」改成一级项(与 Video Review 同级同款),不是灰色分组标签。 +# +# 位置对了但层级不对:原来是「不可点击的小字标题 + 一个 sidebar-link」, +# 视觉上像个分组标签。改成 **一级项(robot 图标 + 可点击 + 选中蓝底)+ +# 二级短名项(训练)**,与 Video Review 的结构一致。 +# +# ★ 顺带修高亮脚本:它原来只遍历 .sidebar-link,而二级项挂的是 +# sidebar-sub —— 不一起遍历的话「训练」永远不会亮。选择器加 +# [data-page] 限定,避开另一段脚本管的 review 状态快捷项。 +# ★ i18n key 改名 nav_group_model → nav_model(它不再是"分组标题")。 +# +# 一并 bump 版本号:浏览器已按 ?v=1.12.0 缓存了 i18n.js 24 小时, +# 不换 URL 的话上面这些文案改动根本到不了前端。 +# 1.12.2 训练页去掉硬编码中文 —— 英文模式下会露出"模型: act""[下载][删除]"。 +# +# 页面标题/页签走了 t(),但 run 头部的「模型/位置」、checkpoint 的 +# 「步骤/文件明细/下载/删除/推理/离线部署」、文件表的三个表头、参数页的分组 +# 标题与「来源:」全都写死成中文 —— 中文模式看不出来,切英文就半中半英。 +# +# 一并两处结构性的: +# • 新增 static/js/train/text.js:i18n 桥接。应用的词典是**经典脚本** +# (只有全局 t(),没有 export),ES module import 不到,三个视图各自去 +# 摸 window.t 不如集中一处 + 防御性回退(取不到就显示英文,不白屏)。 +# • 参数页的分组标题存 **i18n key** 而非翻好的文字:它原来在模块加载那 +# 一刻就定死了,用户之后再切语言不会跟着变。改为渲染时取值。 +# • main.js 接 egodata:lang 重画 —— 本页内容全是 JS 现画的,setLang() +# 只翻 DOM 上的 data-i18n 属性,翻不到动态节点,不重跑就还是旧语言。 +# 1.13.0 训练页加顶部一级页签:「模型训练」+「新建训练」。 +# +# 右侧内容区顶部两个页签(对齐参考实现):模型训练 = 已有产物清单, +# 新建训练 = 五段式向导(选模型 / 选数据 / 导出设置 / 训练参数 / 生成命令)。 +# +# 两个页签都是**真内容**,没有占位: +# • 模型训练 —— 上一版做的就是它,原样搬进来。 +# • 新建训练 —— 新增 static/js/train/newrun.js。选数据那一段走既有的 +# GET /api/v1/ingestion/episodes(训练数据就是采集来的 episode,没 +# 必要另开一套列表)。 +# • 默认超参全部取自**实测那一次**的真实 train_config.json,不是拍的。 +# • **仍然不接后端执行** —— 这一页的产出是一条完整的 lerobot-train +# 命令行(复制 / 下载 .sh)。比放一个点了没反应的"开始训练"有用,而且 +# 表单每个字段就是一个 --key=value,天然定义了将来后端的接口契约。 +# +# ★ 留出集防线:UMIGripper_Action_AI 的 52–73 集模型没见过,重训混进去会 +# 让泛化评估失效 —— 而代码里**没有任何硬编码排除**。UI 上做成可改的区间 +# 输入框(不是写死常数,换数据集时区间就变了):命中行标红 HOLDOUT、默认 +# 不勾、「全选」自动跳过、单独勾选要二次确认。 +# +# ★ 懒渲染:面板第一次显示时才拉数据。否则一进页面就同时请求训练产物和 +# 几百条 episode 列表,而后者多半没人点。 +# ★ dom.js 的 el() 增加 SET_ATTR: 是自定义元素,只监听 +# attribute。`node.icon = x` 不给它挂 attribute,图标画不出来且不报错。 +# 1.14.0 「新建训练」按参考实现重排成六段:位置 / 模型 / 参数 / 数据集 / 续训 / 归属。 +# +# • ① 训练位置 —— 只做本机。勾选参与训练的 GPU,命令里落成 +# CUDA_VISIBLE_DEVICES。云端算力(RunPod/AWS/腾讯/阿里)不摆点不动的卡片。 +# • ② 模型 —— 只 ACT 一张;训练框架 LeRobot 做成**只读文本**而不是下拉框 +# (ACT 只有一种实现,给个下拉框是假的)。 +# • ③ 参数 —— 8 项各带一句说明;默认值仍是**实测那一次**的值 +# (batch_size=16 / steps=20000),不是参考实现的示例值(那个 2/10000 +# 是给它 8GB 的 pi0 用的)。 +# • ④ 数据集 —— **改成按「项目」选(多集)**。原来按 episode 逐个勾: +# 实测 state/exports 下 168 条记录**每条 total_episodes 都是 1**(单集 +# 导出),凑不成训练集;项目树本来就是多集的(UMIGripper_Action_AI 81 集), +# 且被官方加载器验证过。 +# • ⑤ 续训 / ⑥ 项目归属 —— 前者用已有 checkpoint,后者用 /api/v1/projects。 +# • 「创建训练任务」**存在但禁用**,旁边给出真正的产出:完整 lerobot-train +# 命令行(复制 / 下载 .sh)。后端执行以后接,接口契约就是这些 --key=value。 +# +# ★ 留出集防线换了形态。上一版按 episode 勾选,可以"默认不勾 + 全选跳过"; +# 现在选的是整个项目,而留出集就在项目内部(UMIGripper_Action_AI 81 集, +# 留出集是其中第 52–73 集)—— 没法"默认不勾",只能选中会覆盖到留出集的 +# 项目时**明确报警**。区间仍是可改的输入框,不是写死常数。 +# +# 新增两个只读端点: +# GET /api/v1/training/gpus —— 读 nvidia-smi。**驱动没起来时如实报错**, +# 不返回一排 0:0% 利用率看起来像"显卡闲着",而真相是"问不到"。 +# GET /api/v1/training/datasets —— 扫 sessions/<项目>,只列 total_episodes>0 的。 +# 1.14.1 新建训练页两处文案调整。 +# +# ① 训练位置那张卡片改名:「本机 GPU 服务器」→「私有算力」。 +# 原来那个名字只说了"在哪台机器",没说"这是什么性质的算力"。参考实现 +# 那条叫「私有云算力」—— 与自家机器相对的是**租来的云算力**,「私有 +# 算力」才把这条线划清楚。 +# +# ② 删掉两段说明文字: +# • 「目前只做 ACT。其它 LeRobot 内置策略同样可跑,但本平台没实测过。」 +# • 「云端算力(RunPod / AWS / 腾讯 / 阿里)本期不做 —— 不摆点不动的卡片。」 +# 摆一张卡片只在旁边写一句"别的不做"是噪音;真要扩展时直接加卡片即可。 +# +# ③ 段落顺序改成:数据集 → 模型 → 算力(训练位置)→ 参数 → 续训 → 归属。 +# 原来是 位置 → 模型 → 参数 → 数据集。新顺序按**决策依赖**排:先用什么 +# 数据、再训什么模型、再在哪训、最后怎么训 —— 数据集本身就决定命令里的 +# --dataset.root,放最后意味着要先看后面的表单才知道上面该填什么。 +# 注意 newrun.js 里**构造顺序 ≠ 展示顺序**:各段必须先构造好(GPU 面板、 +# 留出集警告、命令生成互有依赖),最后由一个 append 数组决定谁在上面。 +# +# 一并清掉一个死 key(train_files_empty),它定义了但没有引用点。 +# 1.15.0 训练数据集改为「审核页导出后的成品」;导出不再把数据集删掉。 +# +# 原来的链路是断的:审核页导出 → 构建 LeRobot 数据集 → 打包 zip → +# **rmtree 删掉数据集目录**。用户拿到的只有一个 zip,磁盘上什么都不剩, +# 训练页自然无从列起 —— 只能退而列 sessions/<项目> 原始项目。 +# +# 改法(两处,都很小): +# • export.py 打包后调用 _persist_trainable_dataset(),把数据集落到 +# state/datasets/<数据集名>/,而不是删。**交换式替换**:新的先搬进 +# .staging,旧的挪到 .old,再 rename 上位,最后删 .old —— 中途失败 +# 不会让目标位置停在"半个数据集"上(直接 rmtree + move 就会)。 +# • training.py 的 /datasets 改扫 state/datasets/,不再扫 sessions/。 +# +# ★ 为什么这个改法更好:原始项目里混着**未审核**的集,而且**留出集** +# (UMIGripper_Action_AI 的 52–73)就在里面 —— 按项目选等于同时踩两个 +# 坑,UI 上只能报警、拦不住。以导出产物为准之后,两个坑都在导出那一步 +# 被排掉:导出本来就校验 status ∈ {reviewed, approved}。 +# +# ★ 留出集防线**从训练页移走**了。导出后的数据集集号会被重排成 0..N-1, +# 训练页已经认不出哪一集原本是 52–73。控制点转到导出那一步 —— 导出时 +# 别把留出集勾进去。要加硬校验的话,位置在审核/导出页。相关 UI(区间 +# 输入框 + 红色报警)与 parseRanges 一并删除,不留死代码。 +# +# 数据集为空时训练页给的是**可点的引导**(跳 /review?status=approved), +# 而不是一句"没有数据" —— 首次使用时这个列表必然是空的。 +# 1.15.1 「项目归属」改成名副其实的归档标签 —— 之前承诺了做不到的事。 +# +# 那一栏原来写「不选项目时,这次训练是私密的,只有创建者能看到」,是照搬 +# 参考实现的说法。**但本系统的项目根本没有属主**:list_projects() 返回的 +# 字段只有 id/name/description/status/workflow_id/observed_inputs/ +# uploaded_total/params/name_aliases —— 没有 owner、没有 created_by。权限 +# 也是全局角色制(require_roles),不是按项目授权。 +# +# 也就是说"私密任务"这句是**空头支票**:选与不选,能看到的人完全一样。 +# +# 改成写实的说法:它只是个归档标签,便于日后按项目查找这次训练,并**显式 +# 说明没有可见性控制**。将来真要做按项目授权,得先给项目加属主、给训练 +# 任务加属主、再改中间件 —— 那是另一个量级的改动。 +# 1.16.0 新建训练去掉「⑥ 项目归属」;job_name / output_dir 改成真正的参数。 +# +# ① 删掉项目归属那一栏。上一版已经把它的说明改成写实(只是归档标签、不含 +# 可见性控制),但一个**不产生任何行为差异**的下拉框留着就是噪音 —— +# 本系统的项目没有属主字段,选与不选对数据的去向、谁能看到,全都没有 +# 影响。真要按项目授权,得先给项目加属主再谈 UI。 +# 一并删掉 train_sec_project / train_project_* 四个 key,不留死词条。 +# +# ② output_dir 与 job_name 原来是**从项目名推出来的** +# (`文件测试/${项目名}` / `${项目名}`)。项目没了就没有来源,而这两项 +# 本来就是 lerobot-train 的顶层参数,放进「③ 训练参数」更名副其实: +# output_dir 默认 文件测试/umi_act_model(实测那次的值) +# job_name 默认 act +# output_dir 是路径,给的是宽输入框(窄列里看不全)。 +# 1.16.1 前端三处修复 —— Tailwind 类静默失效 / 图标离线化 / 前端版本号。 +# +# ① **训练页一半的 Tailwind 类根本没生效**。tailwind-generated.css 是 +# 构建产物,而全仓**没有任何记录说明它怎么生成** —— 唯一的配置文件是 +# workflow-studio 的,content 范围只有 ./src/**,不含 Jinja 模板和 +# static/js。那份 CSS 停在 8/19,没人能复现。后果是 w-28 / +# text-[10px] / accent-blue-500 / divide-y / break-all 等类全部静默 +# 失效(页面能显示,只是样式没上)。 +# 修法:用覆盖全部前端的 glob **先生成到临时文件**,逐个核对会被删掉 +# 的类(8 个,全部验证为死类 —— 注意别用 grep 直接搜类名,select-all +# 会误命中 id="select-all-bar"),确认是超集后才替换现场。 +# 一并修掉 app/version.py 与两个 README 里「前端版本见 +# workflow-studio/package.json」这个错误说法 —— 那只代表 SPA 自己。 +# 新增 **web/VERSION**(当前 1.9.x)覆盖整个 processor/web/。 +# +# ② 图标收尾。训练页用了 emoji(📁)与字符(⊙),与全站的 iconify 图标 +# 不一致,换成 ant-design:folder-outlined 与 info-circle-outlined。 +# 后者白名单里没有 —— 从 iconify **构建期**拉下来内联进 +# iconify-preload.js(59 → 60 个),运行时仍然不联网。 +# +# ③ web/VERSION 与 README/version.py 的版本号对齐(原来 README 写的是 +# 后端 1.6.8 / 前端 1.5.6,差了十几个版本)。 +# +# 1.16.2 「新建训练」删掉整段「创建训练任务」,checkpoint 改为默认折叠。 +# +# ① 删掉禁用的按钮 + 「后端执行还没接」那句 + 生成的 lerobot-train 命令 +# 行 + 复制/下载。后端接上之前,那两样要么是假按钮、要么是绕路把命令 +# 当交付物。**这一页因此变成纯表单** —— 数据集/GPU/参数/续训都不再 +# 驱动任何输出,它们是为将来后端准备的状态。文件头注释写明了这点, +# 免得下一个人以为坏了。 +# 一并清掉 renderCommand / commandBox / LEROBOT_TRAIN / COPYABLE +# (43 行)、10 个 i18n key(中英双份 20 行)、3 处还写着「六段」 +# 「归属」「下面生成的命令」的过期文案。newrun.js 485 → 379 行。 +# +# ② checkpoint 全部默认折叠。原来让最新的那个展开,结果一进页面就被 +# 11 个文件名顶下去,反而看不到下面还有几个 checkpoint。 +# +# 1.16.3 后端新增 app/training/ 包 —— 训练域从路由层抽出来。 +# +# 与前端 static/js/train/ 对称:routes/training.py 退回成薄薄一层路由, +# 域逻辑进包: +# app/training/gpus.py nvidia-smi 读取 +# app/training/datasets.py state/datasets/ 扫描 +# app/training/runs.py 训练产物(checkpoints)扫描 +# 三个模块都**不认识 HTTP**,返回普通 dict,所以将来 runner 起子进程前 +# 要确认显卡时可以直接复用,不必绕道走一遍路由。 +# +# **这是"新功能先模块化"的第一次实践** —— 域包里的东西没有任何存量引用, +# 抽出来零风险。反例就在旁边:app/ai_annotation.py 长到 180KB 才想起来 +# 该拆,而那时候拆要动 64+ 个 import 点。 +# +# 注:老的 37 个顶层 .py 按域归位是**另一件事**、风险中高(90+ 个 import +# 点跨 worker/scripts/tests/model_eval 五个地方),不在本次范围内。 +# 1.16.4 修训练页半英半中:ES module 在 DOMContentLoaded 之前就渲染了。 +# +# 现象:中文模式下,顶部两个页签是英文(Model Training / New Training), +# 而它下面的子页签(模型输出 / 训练参数)是中文 —— 同一页两种语言。 +# +# 根因是**渲染时机**。i18n.js 的 currentLang 默认写死 'en',要到它自己的 +# DOMContentLoaded 回调里才从 localStorage 读出 'zh'。而 main.js 原来是: +# +# if (document.readyState === 'loading') addEventListener(DOMContentLoaded, boot) +# else boot() +# +# ES module 是 **deferred** 的 —— 它在"文档解析完、DOMContentLoaded 还没 +# 触发"这个中间态执行,那一刻 readyState === 'interactive',所以 +# `=== 'loading'` 为假,直接走 else **当场渲染**。于是: +# 顶部页签(boot 里同步建)→ currentLang 还是 'en' → 英文 +# 子页签(await listRuns 之后才建)→ 已经是 'zh' → 中文 +# 切换语言后能正常,是因为 setLang() 会派发 egodata:lang,而 main.js +# 接了这个事件重跑 boot —— **只有首次加载是坏的**。 +# +# 修法:判据改成 `readyState !== 'complete'`(interactive 也算"还没 fire", +# 这时挂监听一定会被调到)。 +# +# ★ **这是 ES module 独有的坑,老页面不受影响** —— 那 13 个经典脚本都在 +# 解析期执行、老老实实挂 DOMContentLoaded。以后往 static/js/ 下加 +# module 时都得留意这个中间态。 +# +# 一并删掉 train.html 顶部的页面大标题「模型训练」(四个字)+ 它的 i18n +# key。下面的两个页签已经说明"你在哪一页",侧边栏也重复说了同一件事。 +# 那一行改成 justify-end —— 只剩刷新按钮一个子元素时,space-between 会 +# 把它推到左边去。 +# 1.16.5 修训练页内容贴左边 —— 又是 Tailwind 构建产物没跟上。 +# +# 现象:把 train.html 的 `p-6` 拆成 `px-6 pt-3 pb-6` 想让页签上提,结果 +# **左右内边距直接归零**,内容贴到侧边栏上。原因不是 CSS 写错 —— 是 +# px-6 / pb-6 当时**没人用过**,tailwind-generated.css 里根本没有这两条 +# 规则,写了等于没写。而那次改动发生在**上一次重新生成 CSS 之后**。 +# +# ★ 这是同一个坑第二次踩: +# 第一次 → 训练页一半的类(w-28 / text-[10px] / accent-500 / divide-y) +# 因为 CSS 停在几周前而全部静默失效(1.16.1) +# 第二次 → 本次,padding 归零 +# 共同点:**加/改 Tailwind 类之后忘记重新生成,而且全程不报错**。 +# +# 所以这次加了防呆脚本 scripts/check_tailwind_build.sh: +# 用同样的 content 范围重新生成到临时文件,和现场那份 cmp 比对; +# 不一致就报错,`--fix` 则就地重新生成。 +# 跑法:bash scripts/check_tailwind_build.sh [--fix] +# +# ★ 改完 CSS 记得 bump 缓存键。**这条在 1.19.4 之后指的已经不是本文件了** +# —— 缓存键改用 web/VERSION(前端号),见那一条。此处保留原文不改, +# 因为它是当时的事实记录。 +# 1.17.0 训练页加「到点提醒」—— 项目已通过集数达到采集上限时提示可以导出。 +# +# 判据由用户定:**已通过集数 >= 项目设置的采集上限**(params.target_episodes)。 +# 到点只**提醒**,不自动导出 —— 页面上一块绿色提示 + 「去导出」链接。 +# +# ★ 为什么不做全自动(讨论过,用户选了提醒): +# • 导出一次约 **30 秒/集**(UI 自己的 batch_export_hint 文案),1000 集要 +# 跑 8 小时以上;深夜跑、NAS 中途断了没人知道,而现在的导出是全成或全败。 +# • 构建好的数据集会被**固定**下来 —— 已经拿去训练的那份不能悄悄变。 +# 同一个路径指向不同内容,训练就没法复现了(这也是自动导出必须先解决 +# 版本命名的原因)。 +# • 让人点一下,换到的是"我知道现在在导什么、用哪份数据"。 +# +# 实现: +# app/training/projects.py list_projects_progress() +# GET /api/v1/training/ready {projects:[{name,target,uploaded,approved,ready}], ready_count} +# 前端 newrun.js 的 ① 训练数据集里,到点则绿框列出,没到点则给一行「最接近的」 +# (否则那块永远是空的,看不出进展)。 +# +# ★ 两个刻意的取舍: +# (1) **target 为 0 的项目永不 ready** —— 没配上限就没有"到点"这回事, +# 不能把"没配"当成"已达"。 +# (2) **只扫一次 scan_sessions()**。它是全量遍历,数据在 NAS/sshfs 上时 +# 很贵(见 README 的"打开 episode 慢");按项目各扫一遍就是 O(项目数) +# 次全量。上传数与通过数在同一次遍历里一起数出来。 +# +# ⚠ 实测现状:没有一个项目接近上限(52/10000、4/200、3/1000…),所以 +# ready_count 现在恒为 0,页面只会显示「离采集上限最近的」那一行。 +# 这不是 bug —— 现有 target_episodes 是**采集计划量**,而实际工作流里 +# 远没到那个量就会先导出。要不要把上限调成"导出阈值"由用户决定。 +# +# 顺带:新增 scripts/check_tailwind_build.sh,并且**当场抓到了我自己这次 +# 忘了重新生成 CSS**(加了 bg-green-950/40 等新类)。 +# 1.17.1 训练页:删掉 output_dir 表单字段;checkpoint 文件支持下载。 +# +# ① 删 `output_dir`。它本来就不该是前端的字段 —— 输出去哪是**服务端配置** +# (settings.TRAIN_OUTPUT_ROOT,默认 文件测试/umi_act_model,可用环境变量 +# 覆盖),而且训练页正是读它来列 checkpoint 的。让前端再选一次,等于 +# 让用户有机会选到一个页面上根本列不出来的目录。 +# 顺带删掉 WIDE_FIELDS(它只服务这个字段)和相应的宽输入框分支。 +# +# ② checkpoint 文件下载上线(原来按钮是禁用的): +# GET /api/v1/training/runs/{run}/checkpoints/{step}/files/{name:path} +# 逐文件而不是整包:一个 checkpoint 约 600MB,其中 training_state/ +# (优化器状态,约 400MB)**只在续训时有用**;拿去推理/部署只要 +# pretrained_model/(约 200MB)。按需下载比强制拉一个 600MB 的包合理, +# 所以整包下载那个按钮直接去掉了。 +# +# ★ 安全:路径**不做拼接**。直觉写法 `checkpoint_dir / relative_name` 会让 +# `../../../../etc/passwd` 读到任意文件 —— 这个端点是对外开下载的。 +# 改成:先扫一遍拿到磁盘上的真实文件清单,再按名字**精确匹配**。返回的 +# 路径必然来自真实条目,构造不出清单外的。实测四种穿越写法(相对、 +# 带前缀的相对、绝对路径)全部返回 None。 +# (刻意**不用** resolve()+is_relative_to 那种"先拼出来再检查"的写法 —— +# 清单匹配是从源头上就没有越界的可能。) +# +# `{name:path}` 而不是 `{name}`:要下的文件在子目录里 +# (pretrained_model/model.safetensors),默认的路径参数不匹配斜杠。前端拼 +# URL 时也要**逐段编码、保留斜杠** —— 整串 encodeURIComponent 会把斜杠也 +# 编掉,后端就找不到文件。 +# 1.18.0 服务端 i18n —— 修「每次翻页先闪一下英文」。 +# +# 现象:中文用户点任何一个导航,新页面先渲染成英文,再由 JS 纠正成中文。 +# 每次整页导航都闪一下。 +# +# 根因:语言只存在浏览器的 **localStorage** 里 —— 服务端看不到,所以渲染 +# ``data-i18n`` 元素时只能填**默认语言(英文)**,再由 DOMContentLoaded +# 里的 applyTranslations() 改。改的时机在首屏之后,必然闪。 +# +# 改法(用户选的「A 方案」):语言同时写 **cookie**,服务端渲染时就用它取词。 +# +# web/static/js/i18n-data.js ← 词典的**唯一真源**(合法 JSON + 一行赋值) +# app/i18n.py ← 读它 + t(key) + ContextVar + 中间件 +# app/routes/pages.py ← 注册 Jinja 全局 t / current_lang +# base.html / login.html ← +# 8 个模板 132 处 ← 硬编码文案 → {{ t('key') }} +# i18n.js 921 → 130 行 ← 只剩逻辑,词典搬走 +# +# ★ 词典为什么单独一个文件:i18n.js 是 JS,而服务端是 Python。写在里面的话 +# Python 要么重复维护一份、要么去解析 JS 源码。现在两边读**同一份**文件, +# 不会分叉。浏览器当普通脚本同步加载(带 ?v= 缓存)。 +# +# ★ 客户端的 currentLang **以 为准**,不再只读 localStorage。 +# 服务端是从 cookie 读的,两者可能不一致(清了 localStorage 但 cookie +# 还在);一旦不一致,applyTranslations() 会把服务端刚渲染好的中文**又翻 +# 回英文** —— 页面反而变成"先中文后英文",比原来更难看。 +# 以 为准还有个好处:它和 DOM 里的文案**必然一致**(服务端就 +# 是按它渲染的),所以首次 applyTranslations() 是个空操作。 +# +# ★ Jinja 全局 current_lang 注册的是**函数**,模板里必须写 +# ``{{ current_lang() }}`` 带括号 —— 语言是每请求才定的(ContextVar), +# 全局对象本身是固定的。少写括号不报错,只会把函数 repr 渲进 HTML: +# +# +# ⚠ 改这轮的教训:批量正则改模板**连错两次** —— +# 第一次丢掉了 129 处 ` 块里有没有被误伤的 {{ t() }} → 0 处 +# · Jinja 能否解析全部 10 个模板 → 全通过 +# 其中 trash.html 是被"漏掉"的那一个:它的 JS 注释里有一个**示例标签** +# ,被当成真标签处理;而它同时吞掉了 +# 的 ` None: """Persist the result while preserving the two-state review UI.""" - state = read_episode_state(episode_id) - state["video_quality_status"] = "passed" if report.get("passed") else "failed" - state["video_quality_report"] = report - current = str(state.get("status") or "") - if report.get("passed"): - if current in {"to_review", "completed", "reviewed"}: - state["status"] = "reviewed" - if not state.get("approved_at"): - state["approved_at"] = _utcnow() - elif current in {"processing", "completed", "reviewed", "approved", "failed"}: - state["status"] = "to_review" - state["approved_at"] = None - write_episode_state(episode_id, state) + from app.localstore import mutate_episode_state + from app.processing.cleaning.store import is_approval_blocked + + def _apply(state: dict) -> None: + state["video_quality_status"] = "passed" if report.get("passed") else "failed" + state["video_quality_report"] = report + current = str(state.get("status") or "") + if report.get("passed"): + # 视频干净 ≠ 数据干净。数据质检判 FAIL/ERROR 的批次留了粘性标记, + # 这里必须让路 —— 否则质检刚推回 to_review,又被本函数置成 reviewed。 + if (current in {"to_review", "completed", "reviewed"} + and not is_approval_blocked(state)): + state["status"] = "reviewed" + if not state.get("approved_at"): + state["approved_at"] = _utcnow() + elif current in {"processing", "completed", "reviewed", "approved", "failed"}: + state["status"] = "to_review" + state["approved_at"] = None + + # 锁内 RMW:本函数会改 ``status``,而数据质检任务在同一个 run 完成后并发 + # 写同一份 state。分开 read/write 的话,质检那边写回的旧快照会把这里的 + # to_review 冲掉 —— 失败批次看上去像已通过。 + mutate_episode_state(episode_id, _apply) def _utcnow() -> str: diff --git a/processor/app/web_version.py b/processor/app/web_version.py new file mode 100644 index 0000000000000000000000000000000000000000..418502950bbb938ad4efcb1bc5da4eb08d58f0d2 --- /dev/null +++ b/processor/app/web_version.py @@ -0,0 +1,38 @@ +"""前端版本号 —— 读 ``processor/web/VERSION`` 的第一行。 + +一个号,两个用途: + + 1. 模板里的 ``?v=`` 静态资源缓存键 —— 改了 JS/CSS 就 bump 它,浏览器才会重新拉 + 2. 提交信息里的「前端 X」 + +**为什么不让后端号来当缓存键**(那是原来的做法):那样每次改前端都要 bump +*两个*号 —— 后端号给 ``?v=`` 用、前端号给提交信息用 —— 而且两个数对不上时, +看提交信息的人没法判断浏览器该拿哪一版。现在改前端只动一个数。 + +★ 读一次就缓存住(``lru_cache``),不是每请求读盘。代价是**改完 ``web/VERSION`` + 要重启服务才生效** —— 与后端号原来的行为一致(它也是模块级常量)。真要让 + bump 立刻生效,得把它做成可调用对象、模板里写 ``{{ asset_version() }}``, + 那就得改 22 处模板,不划算。 +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +VERSION_FILE = Path(__file__).resolve().parent.parent / "web" / "VERSION" + + +@lru_cache(maxsize=1) +def frontend_version() -> str: + """``web/VERSION`` 的第一行(去空白)。文件缺失/为空时返回 ``"0"``。 + + 读不出来**不抛异常**:静态资源照样能服务,只是 ``?v=`` 退化成一个常量 —— + 页面完全可用,代价仅仅是缓存可能不新鲜。为了让整个站点因为一个版本文件 + 挂掉,不值得。 + """ + try: + first_line = VERSION_FILE.read_text(encoding="utf-8").splitlines()[0] + except (OSError, IndexError): + return "0" + return first_line.strip() or "0" diff --git a/processor/app/workflow_types.py b/processor/app/workflow_types.py index b0d061b549924f4f1df7f8f8af7bf05d4e19f9dd..a91298f86f078d8a1b4fc9b4046f98e1b7b8019b 100644 --- a/processor/app/workflow_types.py +++ b/processor/app/workflow_types.py @@ -18,6 +18,11 @@ LEGACY_TO_CANONICAL: dict[str, str] = { "black_hand_rgb_3d": "rgb_to_2d_black_glove", "stereo_triangulate": "rgbd_to_3d_bare_hand", "black_glove_hand": "rgbd_to_3d_black_glove", + # 质量门禁两个卡片的合并:ai_quality_review(视频检查 + AI 标注覆盖率) + # 与 data_cleaning(按数据类型清洗)功能重叠,合并为 data_quality。 + # 端口 key 刻意不变(data / reviewed),旧工作流的连线可直接复用。 + "ai_quality_review": "data_quality", + "data_cleaning": "data_quality", } RGB_2D_TYPES = { @@ -175,7 +180,9 @@ def migrate_graph_types(graph: dict | None) -> tuple[dict, bool]: handle = str(edge.get("sourceHandle") or "") result_output = ( "annotation" if source_type in {"annotation", "ai_annotation"} - else "reviewed" if source_type in {"human_review", "ai_quality_review"} + # data_quality 是 ai_quality_review 合并后的 slug;node_types 存的是 + # canonical_node_type() 规范化后的值,所以这里只可能见到新名。 + else "reviewed" if source_type in {"human_review", "data_quality"} else "" ) if result_output and handle == "result": diff --git a/processor/deploy.py b/processor/deploy.py index 4c656b1f2359818ee805e06f1a43aa249d49aad0..0f94473d57a3f0d9a72b5d20ceca9fcfdd90c2d0 100644 --- a/processor/deploy.py +++ b/processor/deploy.py @@ -914,10 +914,30 @@ def _systemd_available() -> bool: return rc in (0, 1) and out.strip() in ("running", "degraded", "starting") +# scripts/systemd/host-units/ 那套手工维护的生产单元。它与本脚本渲染的 +# egodata-backend / egodata-workers 是**两套不同的东西**, 并存会抢同一个 +# 8000 端口 —— 谁后启动谁 bind 失败, 然后按 Restart 策略反复重启。 +HOST_UNIT_NAMES = ("egodata-storage", "egodata-db-tunnel", "egodata-api", + "egodata-worker", "egodata-stack-coordinator") + + +def _unit_dir() -> Path: + return Path.home() / ".config" / "systemd" / "user" + + +def _host_units_active() -> bool: + """本机是否已装 host-units 那套手工单元. + + 只要看到 egodata-api.service 就认定是 —— 该单元不由本脚本生成, 它的存在 + 就说明这台机器的服务是由 scripts/systemd/host-units/install.sh 管的。 + """ + return (_unit_dir() / "egodata-api.service").is_file() + + def phase_services_systemd(ports: dict, vllm_enabled: bool) -> None: """渲染并安装 systemd 用户级单元 + enable --now (开机自启).""" src_dir = PROJECT_ROOT / "scripts" / "systemd" - dst_dir = Path.home() / ".config" / "systemd" / "user" + dst_dir = _unit_dir() dst_dir.mkdir(parents=True, exist_ok=True) vllm_url = f"http://127.0.0.1:{ports['vllm_port']}/v1/chat/completions" @@ -1094,6 +1114,17 @@ def phase_services(rep: dict, ports: dict, vllm_enabled: bool, args) -> None: phase_services_foreground(ports, vllm_enabled, rep) return if _systemd_available(): + if _host_units_active(): + # 这台机器的 systemd 已经由 scripts/systemd/host-units/ 接管。 + # 再渲染一套 egodata-backend/egodata-workers 会跟 egodata-api + # 抢 8000 端口, 而且那两个单元指向的 Data Acquisition 目录早已 + # 废弃。这里什么都不装, 直接沿用现有单元。 + warn("已检测到 scripts/systemd/host-units/ 那套手工单元 " + "(egodata-api / worker / storage / db-tunnel)。") + warn("本次跳过 systemd 单元安装, 以免与它争抢端口。") + warn("服务管理: systemctl --user restart egodata-api " + "(前端与后端是同一个进程)") + return phase_services_systemd(ports, vllm_enabled) else: warn("systemd 用户实例不可用 → 退回前台监督模式") @@ -1147,10 +1178,13 @@ def phase_report(rep: dict, ports: dict, vllm_enabled: bool, args) -> None: + (" (⚠️ 已避开被占用的 8001)" if ports["vllm_switched"] else "")) if not IS_WINDOWS and not args.no_services: - states = " ".join( - f"{n}: {_unit_state(n)}" - for n in (["egodata-backend", "egodata-workers"] - + (["egodata-vllm"] if vllm_enabled else []))) + if _host_units_active(): + managed = list(HOST_UNIT_NAMES) + else: + managed = ["egodata-backend", "egodata-workers"] + states = " ".join(f"{n}: {_unit_state(n)}" + for n in managed + + (["egodata-vllm"] if vllm_enabled else [])) say(f" systemd 服务状态: {states}") say(" 开机自启: 已启用 (loginctl linger)") diff --git a/processor/deploy.sh b/processor/deploy.sh old mode 100755 new mode 100644 diff --git a/processor/scripts/archive_sync.sh b/processor/scripts/archive_sync.sh old mode 100755 new mode 100644 diff --git a/processor/scripts/check_icon_preload.sh b/processor/scripts/check_icon_preload.sh old mode 100755 new mode 100644 index 38affd71bb4da2f87bf84a522cc728f150e432ee..699c405d8807f8434993ae2044b5cb327083733f --- a/processor/scripts/check_icon_preload.sh +++ b/processor/scripts/check_icon_preload.sh @@ -6,9 +6,18 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" PRE="$ROOT/web/static/iconify-preload.js" [ -f "$PRE" ] || { echo "找不到 $PRE"; exit 1; } -# 代码里所有 "前缀:图标名" 引用(ant-design:xxx 等) +# 代码里所有 "前缀:图标名" 引用(ant-design:xxx 等)。 +# +# ★ app/processing/modules/ 也必须扫 —— 工作流节点的 icon 是在 Python 里声明的 +# (如 data_cleaning.py 的 ``icon = "ant-design:check-circle-outlined"``), +# 这些图标和前端硬编码的一样会被调色板渲染。曾经漏扫这里,导致新增节点 +# 用了白名单外的图标而检查脚本仍报"全部已预加载"。 +# +# Python 文件里会有 ``cuda:0`` / ``-c:v`` 之类的噪声,但下面只认 ant-design: +# 前缀,噪声会被过滤掉。 USED="$(grep -rhoE "[a-z0-9-]+:[a-z0-9-]+" \ - "$ROOT/web/workflow-studio/src" "$ROOT/web/static/js" "$ROOT/web/templates" 2>/dev/null \ + "$ROOT/web/workflow-studio/src" "$ROOT/web/static/js" "$ROOT/web/templates" \ + "$ROOT/app/processing/modules" 2>/dev/null \ | sort -u)" MISSING="" @@ -19,11 +28,19 @@ while IFS= read -r ref; do *) continue ;; esac name="${ref#ant-design:}" - grep -q "'${name}': { body" "$PRE" || MISSING="$MISSING"$'\n'" $ref" + if ! grep -q "'${name}': { body" "$PRE"; then + # 指出是谁声明的,便于补齐 + WHERE="$(grep -rlE "[\"']?${ref}[\"']?" \ + "$ROOT/web/workflow-studio/src" "$ROOT/web/static/js" "$ROOT/web/templates" \ + "$ROOT/app/processing/modules" 2>/dev/null \ + | sed "s|^$ROOT/||" | paste -sd, -)" + MISSING="$MISSING"$'\n'" $ref ← ${WHERE:-未知位置}" + fi done <<< "$USED" if [ -n "$MISSING" ]; then - echo "以下图标未预加载(页面会出现闪烁/空白),请补进 $PRE :" + echo "以下图标未预加载(iconify-icon 会去 api.iconify.design 拉,页面卡顿/空白)。" + echo "请改用 $PRE 白名单里的图标,或把图标数据补进去:" echo "$MISSING" exit 1 fi diff --git a/processor/scripts/check_tailwind_build.sh b/processor/scripts/check_tailwind_build.sh new file mode 100644 index 0000000000000000000000000000000000000000..ff577e5bf338dcb69c2f5a8faf47874fcaa86764 --- /dev/null +++ b/processor/scripts/check_tailwind_build.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# 检查 web/static/tailwind-generated.css 是否与当前源码同步。 +# +# 为什么需要这个:那份 CSS 是**构建产物**,只包含生成时源码里出现过的类。 +# 往 templates/ 或 static/js/ 里写了一个没被用过的 Tailwind 类(比如把 p-6 +# 改成 px-6 pt-3 pb-6),类就**静默失效** —— 页面照常显示,只是样式没上, +# 而且不报任何错。已经踩过两次: +# 第一次:p-6 拆成 px-6 pt-3 pb-6,左右内边距直接归零,内容贴到侧边栏上 +# 第二次:训练页一半的类(w-28 / text-[10px] / accent-blue-500 / divide-y) +# 因为 CSS 停在几周前而全部失效 +# +# 做法:用同样的 content 范围重新生成一份到临时文件,和现场那份比对。 +# 有差异 = 忘了重新生成(或改了 content 范围)。 +# +# 用法: +# bash scripts/check_tailwind_build.sh # 只检查 +# bash scripts/check_tailwind_build.sh --fix # 检查并就地重新生成 + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEB="$(dirname "$HERE")/web" +OUT="$WEB/static/tailwind-generated.css" +INPUT="$WEB/static/tailwind-input.css" +CONTENT="$WEB/templates/**/*.html,$WEB/static/js/**/*.js" +CLI="$WEB/workflow-studio/node_modules/.bin/tailwindcss" + +if [ ! -x "$CLI" ]; then + echo "找不到 tailwindcss CLI:$CLI" + echo "先跑 cd web/workflow-studio && npm install" + exit 2 +fi + +TMP="$(mktemp -t tw-check-XXXXXX.css)" +trap 'rm -f "$TMP"' EXIT + +"$CLI" -i "$INPUT" -o "$TMP" --content "$CONTENT" >/dev/null 2>&1 + +if cmp -s "$TMP" "$OUT"; then + echo "tailwind-generated.css 与源码同步 ✓" + exit 0 +fi + +if [ "${1:-}" = "--fix" ]; then + cp "$TMP" "$OUT" + echo "已重新生成 tailwind-generated.css" + echo "⚠ 记得 bump app/version.py 的 __version__ —— 它是这个文件的 ?v= 缓存键," + echo " 不 bump 的话浏览器会继续用旧的,新样式到不了前端。" + exit 0 +fi + +echo "❌ tailwind-generated.css 与源码不一致 —— 有类的样式没生效。" +echo " 跑 bash scripts/check_tailwind_build.sh --fix 重新生成" +exit 1 diff --git a/processor/scripts/ensure_stack_linux.sh b/processor/scripts/ensure_stack_linux.sh old mode 100755 new mode 100644 diff --git a/processor/scripts/run_quality_checks.py b/processor/scripts/run_quality_checks.py new file mode 100644 index 0000000000000000000000000000000000000000..e3b7fa3e58cc27ffc96e3f5f1305c75a20c7c752 --- /dev/null +++ b/processor/scripts/run_quality_checks.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""离线跑数据质检 —— 只读数据集,打印真实分布。 + +**这个脚本不写任何东西**:不落盘、不改数据、不碰 state/、不接工作流。 +它的唯一目的是拿真实数据回答"阈值该定多少"——在此之前所有阈值都是猜。 + +引擎是 ``app.processing.cleaning.engine``,本脚本只负责遍历与展示。 + +用法:: + + # 快速(只看 parquet,不碰视频) + python scripts/run_quality_checks.py --dataset + + # 含视频检查(慢,每集要解码抽样) + python scripts/run_quality_checks.py --dataset <路径> --video --limit 5 + + # 看某一集的逐项结果 + python scripts/run_quality_checks.py --dataset <路径> --episode 1 -v + + # 用配置文件覆盖阈值(结构见 data_quality 节点的 config) + python scripts/run_quality_checks.py --dataset <路径> --config cfg.json + +数据集可以是 LeRobot v2.1/v3.0 导出产物(meta/info.json + data/*.parquet), +也可以是 sessions/<项目>/ 下的 canonical 数据。 +""" + +from __future__ import annotations + +import argparse +import glob +import json +import sys +import time +from collections import Counter, defaultdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import numpy as np # noqa: E402 + +from app.processing.cleaning.contract import ( # noqa: E402 + PASS, WARN, FAIL, PENDING, ERROR, SEVERITY_LABELS, +) +from app.processing.cleaning.engine import run_checks, ruleset_revision # noqa: E402 + +_MARK = {PASS: "✓", WARN: "!", FAIL: "✗", PENDING: "?", ERROR: "E"} + + +def iter_episodes(dataset: Path) -> list[tuple[int | None, Path]]: + """列出要跑的 (episode_index, 文件) 组合。 + + canonical 一集一文件 → episode_index 为 None(文件本身就是那一集); + 导出产物一文件多集 → 需要逐个 episode_index 筛。 + """ + out: list[tuple[int | None, Path]] = [] + for path in sorted(glob.glob(str(dataset / "data" / "chunk-*" / "*.parquet"))): + parquet = Path(path) + if parquet.name.startswith("episode_"): + out.append((None, parquet)) # canonical:一集一文件 + else: + import pyarrow.parquet as pq + table = pq.ParquetFile(parquet) + if "episode_index" not in table.schema_arrow.names: + out.append((None, parquet)) + continue + for value in sorted(set(table.read(columns=["episode_index"]) + ["episode_index"].to_pylist())): + out.append((int(value), parquet)) + return out + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dataset", required=True, help="数据集根目录") + parser.add_argument("--limit", type=int, default=0, help="最多跑几集(0=全部)") + parser.add_argument("--episode", type=int, default=None, help="只看这一集") + parser.add_argument("--video", action="store_true", help="跑视频检查(慢)") + parser.add_argument("--config", default=None, help="阈值配置文件(JSON)") + parser.add_argument("--modality", default=None, + help="设备卡片(如 gripper_device);不传则按数据推断") + parser.add_argument("-v", "--verbose", action="store_true", help="逐项打印") + args = parser.parse_args() + + root = Path(args.dataset).expanduser().resolve() + if not root.is_dir(): + print(f"找不到数据集目录: {root}") + return 1 + + config = None + if args.config: + config = json.loads(Path(args.config).read_text(encoding="utf-8")) + + episodes = iter_episodes(root) + if args.episode is not None: + episodes = [item for item in episodes if item[0] == args.episode] + if args.limit: + episodes = episodes[: args.limit] + if not episodes: + print(f"{root} 下没有找到可跑的 episode") + return 1 + + print(f"数据集: {root}") + print(f"集数: {len(episodes)} (**只读,不写入任何东西**)") + print(f"规则集: {ruleset_revision()}") + print() + + per_episode: list[dict] = [] + rule_hits: Counter = Counter() + metric_samples: dict[str, list] = defaultdict(list) + + started = time.time() + for index, _parquet in episodes: + report = run_checks( + root, episode_index=index, + episode_id=f"{root.name}_{index if index is not None else 0:06d}", + project=root.name, config=config, + probe_video=args.video, modality_hint=args.modality, + ) + status = report["episode"]["status"] + per_episode.append({"index": index, "status": status, "report": report}) + + for finding in report["findings"]: + if finding["status"] != PASS: + rule_hits[finding["rule"]] += 1 + for key, value in (finding["metrics"] or {}).items(): + if isinstance(value, (int, float)) and not isinstance(value, bool): + metric_samples[f"{finding['rule']}.{key}"].append(float(value)) + + if args.verbose: + print(f"── 集 {index if index is not None else '-':>5} " + f"{report['modalities'].get('rows', 0):>5} 帧 " + f"{SEVERITY_LABELS.get(status, status)} ──") + for finding in report["findings"]: + print(f" {_MARK.get(finding['status'], ' ')} " + f"{finding['rule']:24s} " + f"{SEVERITY_LABELS.get(finding['status'], finding['status']):6s} " + f"{finding['message'][:56]}") + for item in report["ranges"][:8]: + print(f" [{item['start_sec']:7.2f}s] {item['message'][:52]}") + print() + + # ── 汇总 ──────────────────────────────────────────────── + print("=" * 72) + print(f"跑了 {len(per_episode)} 集,用时 {time.time() - started:.1f} 秒") + print("=" * 72) + print() + + overall = Counter(item["status"] for item in per_episode) + print("★ 按集统计(取每集最严重的):") + for status in (PASS, WARN, FAIL, PENDING, ERROR): + count = overall.get(status, 0) + if count: + print(f" {SEVERITY_LABELS[status]:8s} {count:>4} 集" + f" ({count / len(per_episode):>5.1%})") + print() + + print("★ 命中的检查项:") + if not rule_hits: + print(" (没有任何检查项报警)") + for rule, count in rule_hits.most_common(): + print(f" {rule:28s} {count:>4} 集") + print() + + print("★ 关键指标的真实分布(定阈值要用这个):") + for key in sorted(metric_samples): + values = np.asarray(metric_samples[key], dtype=float) + if values.size < 2: + continue + print(f" {key:48s} " + f"中位 {np.median(values):>10.4f} " + f"p90 {np.percentile(values, 90):>10.4f} " + f"max {values.max():>10.4f}") + print() + print("★ 未写入任何文件。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/processor/scripts/systemd/host-units/egodata-watchdog.sh b/processor/scripts/systemd/host-units/egodata-watchdog.sh old mode 100755 new mode 100644 diff --git a/processor/scripts/systemd/host-units/install.sh b/processor/scripts/systemd/host-units/install.sh old mode 100755 new mode 100644 diff --git a/processor/tests/test_check_video.py b/processor/tests/test_check_video.py new file mode 100644 index 0000000000000000000000000000000000000000..8bc5b5136e519a7d7344e5389f7bbcba12cb113f --- /dev/null +++ b/processor/tests/test_check_video.py @@ -0,0 +1,199 @@ +"""视频类检查项单测。 + +这些检查项**不读磁盘** —— 证据由 evidence.py 采集好放进 StreamEvidence, +测试直接构造假证据即可,不需要真 mp4 或 cv2。这正是当初把 IO 做成注入的目的。 + + pytest tests/test_check_video.py + python3 tests/test_check_video.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.processing.cleaning.checks import ( # noqa: E402 + CheckContext, StreamEvidence, get_check, PASS, WARN, FAIL, ERROR, +) + + +def _ctx(streams, **kwargs) -> CheckContext: + """装一个最小的执行上下文。""" + return CheckContext(batch_dir=Path("/tmp/nonexistent"), + streams=tuple(streams), **kwargs) + + +def _rgb(key="umi_rgb", **video) -> StreamEvidence: + return StreamEvidence(key=key, channel="rgb", fps=30.0, video=video) + + +# ── video.frame_drop ──────────────────────────────────────── +def test_frame_drop_passes_within_tolerance(): + check = get_check("video.frame_drop") + ctx = _ctx([_rgb(frame_count=1188, reason="passed")], data_rows=1190) + findings = check.run(ctx) + assert len(findings) == 1 + assert findings[0].status == PASS + + +def test_frame_drop_warns_on_small_gap(): + check = get_check("video.frame_drop") + # 差 20 帧 / 容差 max(2, 1200*0.005=6) = 6 → 20 > 6*5=30? 否 → WARN + ctx = _ctx([_rgb(frame_count=1180, reason="passed")], data_rows=1200) + findings = check.run(ctx) + assert findings[0].status == WARN + assert findings[0].metrics["diff"] == 20 + assert findings[0].metrics["tolerance"] == 6 + + +def test_frame_drop_fails_on_large_gap(): + check = get_check("video.frame_drop") + # 差 500 帧 >> 容差*5 → FAIL + ctx = _ctx([_rgb(frame_count=700, reason="passed")], data_rows=1200) + assert check.run(ctx)[0].status == FAIL + + +def test_frame_drop_tolerance_floor_for_small_batches(): + """小批次按绝对帧数兜底:100 行 × 0.5% = 0 → 用下限 2。""" + check = get_check("video.frame_drop") + ctx = _ctx([_rgb(frame_count=98, reason="passed")], data_rows=100) + findings = check.run(ctx) + assert findings[0].metrics["tolerance"] == 2 + assert findings[0].status == PASS + + +def test_frame_drop_ignores_unreadable_video(): + """打不开的流不该被算成"丢帧" —— 那是 decode_error 的职责。""" + check = get_check("video.frame_drop") + ctx = _ctx([_rgb(frame_count=0, reason="video_open_failed")], data_rows=1200) + assert check.run(ctx) == [] + + +def test_frame_drop_uses_node_params_over_defaults(): + """节点配置的阈值要盖过 default_params。""" + check = get_check("video.frame_drop") + ctx = _ctx([_rgb(frame_count=900, reason="passed")], data_rows=1000, + params={"tolerance_ratio": 0.5, "tolerance_min": 500}) + assert check.run(ctx)[0].status == PASS + + +# ── video.black_screen ────────────────────────────────────── +def test_black_screen_passes_when_no_ranges(): + check = get_check("video.black_screen") + assert check.run(_ctx([_rgb(frame_count=300, reason="passed")])) == [] + + +def test_black_screen_reports_ranges_and_frames(): + check = get_check("video.black_screen") + # 30 秒里黑 1 秒 = 3.3%,低于 fail_ratio(5%) → WARN + ctx = _ctx([_rgb(frame_count=900, fps=30.0, reason="black_screen", + black_ranges=[[12.0, 13.0]])], + duration_sec=30.0) + finding = check.run(ctx)[0] + assert finding.status == WARN + assert finding.metrics["segments"] == 1 + assert len(finding.ranges) == 1 + item = finding.ranges[0] + assert item.start_sec == 12.0 and item.end_sec == 13.0 + assert item.start_frame == 360 and item.end_frame == 390 + + +def test_black_screen_fails_when_ratio_exceeds_threshold(): + """占比超过 5% → FAIL。30 秒里黑 3 秒 = 10%。""" + check = get_check("video.black_screen") + ctx = _ctx([_rgb(frame_count=900, reason="black_screen", + black_ranges=[[0.0, 3.0]])], + duration_sec=30.0) + assert check.run(ctx)[0].status == FAIL + + +def test_black_screen_drops_ranges_below_min_sec(): + """短于 min_sec(0.5s) 的黑屏段忽略 —— 避免逐帧闪烁刷屏。""" + check = get_check("video.black_screen") + ctx = _ctx([_rgb(frame_count=900, reason="black_screen", + black_ranges=[[1.0, 1.2]])], + duration_sec=30.0) + assert check.run(ctx) == [] + + +# ── video.freeze ──────────────────────────────────────────── +def test_freeze_warns_and_escalates_by_duration(): + check = get_check("video.freeze") + short = _ctx([_rgb(frame_count=900, reason="freeze_suspected", + freeze_ranges=[[5.0, 8.0]])]) + assert check.run(short)[0].status == WARN # 3 秒 < fail_sec(10) + + long = _ctx([_rgb(frame_count=900, reason="freeze_suspected", + freeze_ranges=[[5.0, 20.0]])]) + assert check.run(long)[0].status == FAIL # 15 秒 >= 10 + + +def test_freeze_metrics_report_longest(): + """longest_sec 取最长的那一段:4.0s 和 5.0s → 5.0。""" + check = get_check("video.freeze") + ctx = _ctx([_rgb(frame_count=900, reason="freeze_suspected", + freeze_ranges=[[5.0, 9.0], [20.0, 25.0]])]) + finding = check.run(ctx)[0] + assert finding.metrics["segments"] == 2 + assert finding.metrics["longest_sec"] == 5.0 + + +# ── video.decode_error ────────────────────────────────────── +def test_decode_error_passes_without_errors(): + check = get_check("video.decode_error") + ctx = _ctx([_rgb(frame_count=300, reason="passed", decode_error_count=0)]) + assert check.run(ctx)[0].status == PASS + + +def test_decode_error_fails_and_marks_frames(): + check = get_check("video.decode_error") + ctx = _ctx([_rgb(frame_count=300, fps=30.0, reason="decode_error", + decode_error_count=3, decode_errors=[30, 60, 90])]) + finding = check.run(ctx)[0] + assert finding.status == FAIL + assert len(finding.ranges) == 3 + assert finding.ranges[0].start_frame == 30 + assert finding.ranges[0].start_sec == 1.0 + + +def test_decode_error_marks_opencv_missing_as_error_not_fail(): + """缺 OpenCV 是环境问题,不该记成数据质量事故。""" + check = get_check("video.decode_error") + ctx = _ctx([_rgb(reason="opencv_unavailable")]) + assert check.run(ctx)[0].status == ERROR + + +def test_decode_error_marks_unopenable_video_as_fail(): + check = get_check("video.decode_error") + ctx = _ctx([_rgb(reason="video_open_failed", error="moov atom not found")]) + finding = check.run(ctx)[0] + assert finding.status == FAIL + assert "moov atom" in finding.metrics["error"] + + +# ── 跨流 ──────────────────────────────────────────────────── +def test_checks_iterate_all_rgb_streams(): + """双目项目有两路 RGB,都要检查。""" + check = get_check("video.frame_drop") + ctx = _ctx([_rgb("cam_left", frame_count=700, reason="passed"), + _rgb("cam_right", frame_count=1200, reason="passed")], + data_rows=1200) + findings = check.run(ctx) + assert {item.stream for item in findings} == {"cam_left", "cam_right"} + assert [item.status for item in findings].count(FAIL) == 1 + + +if __name__ == "__main__": + import traceback + failures = 0 + for name, fn in sorted(globals().items()): + if not name.startswith("test_") or not callable(fn): + continue + try: + fn() + print(f" ✓ {name}") + except Exception: + failures += 1 + print(f" ✗ {name}") + traceback.print_exc() + print(f"\n{'FAILED' if failures else 'OK'} ({failures} 个失败)") + sys.exit(1 if failures else 0) diff --git a/processor/tests/test_cleaning_collect.py b/processor/tests/test_cleaning_collect.py new file mode 100644 index 0000000000000000000000000000000000000000..9e1b1008f3b047f35cc33fae99a6e47ffcf54c75 --- /dev/null +++ b/processor/tests/test_cleaning_collect.py @@ -0,0 +1,231 @@ +"""采集层的两条静默失效,各上一把锁。 + +它们都不报错、报告照常生成,只是内容是错的: + + 1. **非首集读出空帧** —— canonical 是一集一文件,曾经取 ``parquets[0]`` 再按 + ``episode_index`` 列筛行,于是除第 0 集外全筛成空。报告退化成 + ``empty_report``(``episode.status = ERROR``),在"数据不对就拦回人工审核" + 的链路里表现为**每一集都被卡住**。 + 2. **触觉阵列被截断** —— ``_CHANNEL_WIDTH`` 没有 ``tactile`` 项,落到 3 的 + 兜底值,16×16=256 个感应点被静默截成 3 个,检查项只看得到 1%。 + +外加两套命名的去重(``observation.tactile.*`` 与 ``observation.*_glove``)。 + +造的是最小 fixture,不依赖真实数据、不需要 cv2。 + + pytest tests/test_cleaning_collect.py + python3 tests/test_cleaning_collect.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # noqa: E402 + +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 + +from app.processing.cleaning import collect # noqa: E402 + + +# ── fixture ───────────────────────────────────────────────── +def _canonical_batch(root: Path, episodes) -> Path: + """canonical 布局:一集一文件。``episodes`` 是 {集号: 行数}。""" + (root / "data" / "chunk-000").mkdir(parents=True, exist_ok=True) + (root / "meta").mkdir(exist_ok=True) + for index, rows in episodes.items(): + pd.DataFrame({ + "episode_index": np.full(rows, index, dtype="int64"), + "frame_index": np.arange(rows, dtype="int64"), + "timestamp": np.arange(rows, dtype=float) / 30.0, + }).to_parquet(root / "data" / "chunk-000" / f"episode_{index:06d}.parquet") + return root + + +def _tactile_batch(root: Path, *, columns, frames=8, width=256) -> Path: + """带触觉阵列的批次。``columns`` 决定用哪套命名。""" + (root / "data" / "chunk-000").mkdir(parents=True, exist_ok=True) + (root / "meta").mkdir(exist_ok=True) + data = {"timestamp": np.arange(frames, dtype=float) / 30.0} + for name in columns: + data[name] = [list(np.full(width, float(i))) for i in range(frames)] + pd.DataFrame(data).to_parquet(root / "data" / "chunk-000" / "episode_000000.parquet") + return root + + +# ── 定位这一集的数据文件 ───────────────────────────────────── +def test_picks_the_file_for_the_requested_episode(tmp_path): + """★ 核心回归:非首集必须读到**自己**那个文件,而不是空帧。""" + batch = _canonical_batch(tmp_path / "b", {0: 10, 1: 20, 7: 30}) + + for index, rows in ((0, 10), (1, 20), (7, 30)): + path, filter_rows = collect.resolve_episode_data(batch, index) + assert path is not None and path.name == f"episode_{index:06d}.parquet" + assert filter_rows is False, "canonical 一集一文件,不该再按列筛行" + assert len(collect.load_rows(path)) == rows + + +def test_the_original_bug_is_actually_a_bug(tmp_path): + """把当初的写法钉在这里:它**不报错**,只是静默返回空帧。 + + 当初是「取 ``parquets[0]`` 再按 ``episode_index`` 列筛行」。canonical 每一集 + 一个文件,第 0 个文件里只有第 0 集的行 —— 拿它去筛第 3 集得到 0 行。 + """ + batch = _canonical_batch(tmp_path / "b", {0: 10, 3: 25}) + first = collect.resolve_episode_data(batch, 0)[0] + + assert len(collect.load_rows(first, 3)) == 0 # ← 当初的 bug + assert len(collect.load_rows(first, 0)) == 10 # 只有筛自己那集才对 + + # 现在的做法:选对文件,且**不再按列筛行** + third, filter_rows = collect.resolve_episode_data(batch, 3) + assert filter_rows is False + assert len(collect.load_rows(third, None)) == 25 + + +def test_gather_evidence_reads_the_requested_episode(tmp_path): + """★ 端到端那把锁。 + + 上面两条直接测 ``resolve_episode_data``,**测不到接线**:把 + ``gather_evidence`` 改回"取第一个文件 + 按列筛行",它们照样全绿。而当初 + 出 bug 的正是接线那一处。这里断言的是采集**结果**。 + """ + batch = _canonical_batch(tmp_path / "b", {0: 10, 3: 25, 9: 40}) + + for index, rows in ((0, 10), (3, 25), (9, 40)): + evidence = collect.gather_evidence(batch, episode_index=index) + assert evidence.rows == rows, ( + f"episode {index} 读到 {evidence.rows} 行、应为 {rows} 行 —— " + f"多半是又退回了「取第一个文件再按列筛行」") + assert evidence.notes.get("reason") is None, "退化成空报告了" + + +def test_missing_episode_returns_none(tmp_path): + batch = _canonical_batch(tmp_path / "b", {0: 5}) + assert collect.resolve_episode_data(batch, 99)[0] is None + + +def test_multi_episode_layout_still_filters_rows(tmp_path): + """导出产物是一文件多集,那条路必须继续按行筛。""" + root = tmp_path / "b" / "data" / "chunk-000" + root.mkdir(parents=True) + pd.DataFrame({ + "episode_index": np.repeat([4, 5], 6), + "timestamp": np.arange(12, dtype=float), + }).to_parquet(root / "file-000.parquet") + + path, filter_rows = collect.resolve_episode_data(tmp_path / "b", 5) + assert path is not None and filter_rows is True + frame = collect.load_rows(path, 5) + assert len(frame) == 6 and set(frame["episode_index"]) == {5} + + +# ── 触觉阵列 ──────────────────────────────────────────────── +def test_tactile_array_is_not_truncated(tmp_path): + """★ 256 个感应点必须原样保留 —— 截成 3 个检查项就废了。""" + batch = _tactile_batch(tmp_path / "b", columns=["observation.left_glove"]) + evidence = collect.gather_evidence(batch) + + stream = next(s for s in evidence.streams if s.channel == "tactile") + series = stream.series["tactile"] + assert len(series[0]) == 256, f"触觉阵列被截断成 {len(series[0])}" + + +def test_unknown_channel_is_not_truncated(): + """兜底宽度不能是某个具体数字 —— 换设备时同样会静默丢数据。""" + assert collect.vector_series([[1.0] * 40])[0] == tuple([1.0] * 40) + assert len(collect.vector_series([[1.0] * 40], 7)[0]) == 7 # 显式声明才截断 + + +def test_tactile_alias_is_recognized(tmp_path): + """★ 只跑过导出的树上只有 observation.tactile.* —— 以前认不出,手套检查 + 整条静默跳过。""" + batch = _tactile_batch(tmp_path / "b", columns=["observation.tactile.left"]) + evidence = collect.gather_evidence(batch) + + assert "tactile" in evidence.channels + keys = [s.key for s in evidence.streams if s.channel == "tactile"] + assert keys == ["left_glove"], f"别名列没归一成协议流名: {keys}" + + +def test_both_naming_schemes_do_not_duplicate(tmp_path): + """两套命名同时存在时是同一份数据,登记成两条流会让缺陷被报两次。""" + batch = _tactile_batch( + tmp_path / "b", + columns=["observation.left_glove", "observation.tactile.left"]) + evidence = collect.gather_evidence(batch) + + keys = [s.key for s in evidence.streams if s.channel == "tactile"] + assert keys == ["left_glove"], f"同一份数据被登记了多条流: {keys}" + + +def test_one_handed_episode_yields_one_stream(tmp_path): + """同项目内 schema 不一致是真实存在的(D435 第 3 集只有右手)。 + + 采集层只要"有什么收什么"即可;检查项不能假设双手都在。 + """ + batch = _tactile_batch(tmp_path / "b", columns=["observation.right_glove"]) + evidence = collect.gather_evidence(batch) + + keys = [s.key for s in evidence.streams if s.channel == "tactile"] + assert keys == ["right_glove"] + assert evidence.modalities == ("glove_sensor",) + + +# ── SLAM 列的多相机命名 ───────────────────────────────────── +# +# 双目/多相机把第二路存成 observation.<相机名>_slam_*,而采集只认不带前缀的 +# 两个名字 —— 那种集**有 SLAM 数据、却一个 SLAM 检查都不跑**,且不报任何错 +# (实测 Test94/episode_000003 只有 gripper_2 前缀,整集 SLAM 检查全空)。 +def test_slam_single_camera_prefers_trajectory(): + """一个相机两列都在时只取一条 —— trajectory 是高频样本缓冲。""" + assert collect._slam_columns( + {"observation.slam_trajectory", "observation.slam_pose"} + ) == ["observation.slam_trajectory"] + + +def test_slam_prefixed_camera_is_recognized(): + """★ 回归:只有 observation.<相机名>_slam_* 时也要认出来。""" + picked = collect._slam_columns( + {"observation.gripper_2_slam_trajectory", "observation.gripper_2_slam_pose"}) + assert picked == ["observation.gripper_2_slam_trajectory"] + + +def test_slam_multiple_cameras_yield_one_stream_each(): + """★ 双目:每个相机各一条流 —— 之前是全局 break,只取第一路。""" + picked = collect._slam_columns({ + "observation.slam_pose", "observation.gripper_2_slam_pose"}) + assert sorted(picked) == ["observation.gripper_2_slam_pose", + "observation.slam_pose"] + + +def test_slam_ignores_lookalike_columns(): + """名字里含 slam 但不是位姿的列不能被当成 SLAM。""" + assert collect._slam_columns({"observation.slam_trajectory_ns"}) == [] + assert collect._slam_columns( + {"processing.umi_slam_action.data_4.action"}) == [] + # 伴生列在场也不影响正常列 + assert collect._slam_columns( + {"observation.slam_pose", "observation.slam_trajectory_ns"} + ) == ["observation.slam_pose"] + + +def test_gather_evidence_reads_prefixed_slam(tmp_path): + """端到端:只有前缀命名的集也要产出 slam 流与 slam 信道。""" + root = tmp_path / "b" / "data" / "chunk-000" + root.mkdir(parents=True) + (tmp_path / "b" / "meta").mkdir() + rows = 6 + pd.DataFrame({ + "observation.gripper_2_slam_pose": [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]] * rows, + }).to_parquet(root / "episode_000000.parquet") + + evidence = collect.gather_evidence(tmp_path / "b") + slam = [s for s in evidence.streams if s.channel == "slam"] + assert [s.key for s in slam] == ["gripper_2_slam_pose"] + assert "slam" in evidence.channels + + +if __name__ == "__main__": + import pytest + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/processor/tests/test_cleaning_config.py b/processor/tests/test_cleaning_config.py new file mode 100644 index 0000000000000000000000000000000000000000..7a02c3350594a9195f4641bfb606173d7e8c378b --- /dev/null +++ b/processor/tests/test_cleaning_config.py @@ -0,0 +1,170 @@ +"""质检配置解析单测(engine.resolve_config / applicable_checks 的用法 + +trigger 的工作流接线判定)。 + +**这个文件盯的是一类 bug:检查项静默不跑。** 报告照常生成、``passed`` 照常 +写,只是有一条本该跑的检查根本没执行 —— 没有任何异常、没有任何日志。项目里 +已经因此踩过好几次,所以这里每条都用"跑了几项"来断言,而不是断言没抛异常。 + +全是纯函数 + 一次针对临时目录的引擎调用,不需要真视频、不需要 cv2。 + + pytest tests/test_cleaning_config.py + python3 tests/test_cleaning_config.py +""" +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # noqa: E402 + +from app.processing.cleaning import engine # noqa: E402 +from app.processing.cleaning.collect import _detect_modalities # noqa: E402 +from app.processing.cleaning.trigger import data_quality_target # noqa: E402 + + +# ── 模态推断 ──────────────────────────────────────────────── +def test_infers_from_channels_when_no_hint(): + assert _detect_modalities({"rgb"}, None) == ("mono_rgb",) + assert _detect_modalities({"tactile"}, None) == ("glove_sensor",) + assert _detect_modalities({"slam", "rgb"}, None) == ("gripper_device",) + + +def test_alias_node_types_normalize(): + # 工作流节点类型用的是 camera 后缀,要映射到 canonical 设备模态 + assert _detect_modalities({"rgb"}, "mono_camera") == ("mono_rgb",) + assert _detect_modalities({"depth"}, "rgbd_camera") == ("rgbd_camera",) + + +def test_hint_wins_for_modalities_inference_cannot_produce(): + """★ ``stereo_rgb`` 推断不出来 —— "立体"不是列名里看得见的特征。 + + 没有这条,双目工作流在设置面板里配的阈值会被静默忽略(查不到那个 tab)。 + """ + assert "stereo_rgb" in _detect_modalities({"rgb"}, ["stereo_rgb"]) + assert "stereo_rgbd_camera" in _detect_modalities({"depth"}, ["stereo_rgbd_camera"]) + + +def test_hint_and_inference_are_unioned(): + """两个来源各有盲区,取并集 —— 缺一个都会有检查项不跑。""" + got = _detect_modalities({"rgb", "depth"}, ["stereo_rgbd_camera"]) + assert "stereo_rgbd_camera" in got # 来自 hint + assert "rgbd_camera" in got # 来自数据推断 + + +def test_multiple_hints_keep_order(): + """多设备时工作流连线的顺序 = 用户预期顺序(resolve_config 先出现的优先)。""" + assert _detect_modalities({"rgb", "slam"}, ["gripper_device", "mono_rgb"]) == \ + ("gripper_device", "mono_rgb") + + +# ── 工作流接线判定 ────────────────────────────────────────── +def _graph(nodes, edges): + return { + "nodes": [{"id": i, "data": {"nodeType": t}} for i, t in nodes], + "edges": [{"source": s, "target": t} for s, t in edges], + } + + +def test_target_requires_incoming_edge(): + """孤零零摆一张卡片只是配置,不该触发 —— 与 video_quality_gate 同语义。""" + assert data_quality_target(_graph([("q", "data_quality")], []), {}) is None + + +def test_target_accepts_gripper_device(): + """★ ``gripper_device`` 不在 video_quality_gate_config 的源类型白名单里。 + + 那条白名单漏一个设备类型,卡片就静默失效一次。这里用"有入边"当判据, + 所以 UMI 工作流能正常触发 —— 这是本项目的主用例。 + """ + got = data_quality_target( + _graph([("a", "gripper_device"), ("q", "data_quality")], [("a", "q")]), {}) + assert got is not None and got[1] == ("gripper_device",) + + +def test_target_walks_through_process_nodes(): + """相机 → 处理节点 → 质检:直接上游是处理节点,要往回找到设备卡片。""" + got = data_quality_target( + _graph([("a", "mono_camera"), ("p", "rgbd_to_3d_bare_hand"), ("q", "data_quality")], + [("a", "p"), ("p", "q")]), {}) + assert got is not None and got[1] == ("mono_rgb",) + + +def test_target_handles_legacy_slug(): + """合并前的旧 slug 要能识别(已保存的工作流打开时自动迁移)。""" + for legacy in ("ai_quality_review", "data_cleaning"): + got = data_quality_target( + _graph([("a", "gripper_device"), ("q", legacy)], [("a", "q")]), {}) + assert got is not None, legacy + + +def test_target_merges_node_configs_over_card(): + """``node_configs`` 覆盖卡片里的 ``data.config``(与既有的卡片配置读取一致)。""" + graph = _graph([("a", "gripper_device"), ("q", "data_quality")], [("a", "q")]) + graph["nodes"][1]["data"]["config"] = {"devices": {"stale": {}}} + cfg, _ = data_quality_target(graph, {"q": {"devices": {"gripper_device": {}}}}) + assert "gripper_device" in cfg["devices"] + + +# ── ★ 配置是黑名单,不是白名单 ────────────────────────────── +# +# 前端设置面板只在用户**碰过**的检查项上写 entry,未触碰的不进 config。 +# 曾把"显式启用的那些"当白名单传进 applicable_checks(enabled=...), +# 后果是「在弹窗里改一个阈值 → 其余检查项全部静默停跑」: +# 实测只配 umi.gripper_range 时 9 项变 1 项,不报任何错。 +# +# 下面用真实引擎跑一个最小批次(只有 parquet,无视频),断言项数。 +def _minimal_batch(tmp: Path) -> Path: + """造一个只有 UMI 标量列的批次 —— 够触发若干检查项,又不依赖 cv2。""" + import numpy as np + import pandas as pd + + import json + + # 目录层级不能省:find_episode_parquets 只认 data/chunk-*/episode_*.parquet + (tmp / "data" / "chunk-000").mkdir(parents=True, exist_ok=True) + (tmp / "meta").mkdir(exist_ok=True) + (tmp / "meta" / "info.json").write_text( + json.dumps({"fps": 30.0}), encoding="utf-8") + + rows = 30 + pd.DataFrame({ + # 列名必须与 _COLUMN_CHANNELS 逐字一致 —— 对不上不会有任何报错, + # 只是信道识别不出来、检查项静默不跑(正是本文件要盯住的那类 bug) + "timestamp": np.arange(rows, dtype=float) / 30.0, + "observation.gripper_state": np.linspace(0.0, 1.0, rows), + "observation.gripper_left_force": np.linspace(0.0, 5.0, rows), + "action": np.tile(np.arange(7, dtype=float), (rows, 1)).tolist(), + }).to_parquet(tmp / "data" / "chunk-000" / "episode_000000.parquet") + return tmp + + +def _checks_run(config: dict) -> list[str]: + with tempfile.TemporaryDirectory() as tmp: + batch = _minimal_batch(Path(tmp) / "batch") + report = engine.run_checks(batch, episode_id="t", config=config) + return sorted(report["modalities"]["checks_run"]) + + +def test_config_only_touched_entry_does_not_disable_the_rest(): + """配了一项阈值 ≠ 只跑这一项。""" + baseline = _checks_run({}) + assert baseline, "夹具没触发任何检查项,测试无意义" + + only_one = _checks_run({"devices": {"gripper_device": {"checks": { + "umi.gripper_range": {"enabled": True, "params": {"max_force_drift": 500}}, + }}}}) + assert only_one == baseline + + +def test_explicit_disable_still_works(): + """黑名单那半边必须仍然生效 —— 别把白名单 bug 修成"配置完全无效"。""" + baseline = _checks_run({}) + disabled = _checks_run({"devices": {"gripper_device": {"checks": { + "umi.gripper_range": {"enabled": False}, + }}}}) + assert "umi.gripper_range" not in disabled + assert len(disabled) == len(baseline) - 1 + + +if __name__ == "__main__": + import pytest + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/processor/tests/test_cleaning_contract.py b/processor/tests/test_cleaning_contract.py new file mode 100644 index 0000000000000000000000000000000000000000..68afe60f5369ea62f995e5b4058711d50f9f05fa --- /dev/null +++ b/processor/tests/test_cleaning_contract.py @@ -0,0 +1,318 @@ +"""清洗契约与检查项注册表单测。 + +覆盖:设备模态与 device_naming 的口径一致性(漂移守卫)、别名映射、 +信道推导、严重度合成、检查项筛选。 + + pytest tests/test_cleaning_contract.py + python3 tests/test_cleaning_contract.py # 无 pytest 也能跑 +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.device_naming import DEVICE_LABELS # noqa: E402 +from app.processing.cleaning import contract as C # noqa: E402 +from app.processing.cleaning import checks as K # noqa: E402 + + +# ── 漂移守卫 ──────────────────────────────────────────────── +def test_device_modalities_match_device_naming(): + """两个文件声明的设备模态必须逐字一致。 + + 这是本套设计里唯一靠"人工保持同步"的地方 —— 所以用测试锁死。 + 任何人在 device_naming 加了新设备类型而忘了同步这里,这个测试立刻红。 + """ + assert set(C.DEVICE_MODALITIES) == set(DEVICE_LABELS) + + +def test_every_channel_of_every_modality_is_declared(): + """MODALITY_CHANNELS 用到的信道都必须在 CHANNELS 里声明。""" + declared = set(C.CHANNELS) + for modality in C.DEVICE_MODALITIES: + assert modality in C.MODALITY_CHANNELS, f"{modality} 没声明信道" + unknown = set(C.MODALITY_CHANNELS[modality]) - declared + assert not unknown, f"{modality} 用了未声明的信道: {unknown}" + + +def test_aliases_map_into_canonical_set(): + """别名表的值必须是合法的设备模态。""" + for alias, target in C._ALIASES.items(): + assert target in C.DEVICE_MODALITIES, f"{alias} → {target} 不是合法模态" + + +# ── 别名映射 ──────────────────────────────────────────────── +def test_normalize_accepts_canonical_and_alias(): + # canonical 值原样返回 + assert C.normalize_device_modality("mono_rgb") == "mono_rgb" + assert C.normalize_device_modality("gripper_device") == "gripper_device" + # api/projects 与处理模块 slug 用的 camera 后缀写法 + assert C.normalize_device_modality("mono_camera") == "mono_rgb" + assert C.normalize_device_modality("stereo_camera") == "stereo_rgb" + # 历史节点类型 + assert C.normalize_device_modality("fisheye_camera") == "mono_rgb" + + +def test_normalize_rejects_unknown_without_raising(): + """认不出来要返回 None 而不是抛异常 —— 调用方据此跳过。""" + assert C.normalize_device_modality("") is None + assert C.normalize_device_modality(None) is None + assert C.normalize_device_modality("some_future_sensor") is None + + +def test_normalize_accepts_whitespace(): + assert C.normalize_device_modality(" mono_rgb ") == "mono_rgb" + + +# ── 信道推导 ──────────────────────────────────────────────── +def test_channels_for_unions_and_dedupes(): + channels = C.channels_for(["gripper_device", "mono_rgb"]) + assert channels[0] == "rgb" # 保序:gripper_device 先出现 + assert "time" in channels + assert len(channels) == len(set(channels)), "有重复信道" + + +def test_channels_for_unknown_modality_is_empty(): + assert C.channels_for(["not_a_modality"]) == () + assert C.channels_for([]) == () + + +# ── 严重度 ────────────────────────────────────────────────── +def test_worst_picks_most_severe(): + assert C.worst([C.PASS, C.WARN]) == C.WARN + assert C.worst([C.WARN, C.FAIL]) == C.FAIL + assert C.worst([C.FAIL, C.ERROR]) == C.ERROR + assert C.worst([C.ERROR, C.PENDING, C.WARN]) == C.ERROR + + +def test_worst_of_empty_is_pass(): + assert C.worst([]) == C.PASS + + +def test_worst_ignores_unknown_status_names(): + """未知状态名按"最轻"处理,不崩溃 —— 新旧报告混读时不炸。""" + assert C.worst([C.PASS, "some_new_status"]) == C.PASS + + +def test_is_blocking_only_for_fail_and_error(): + assert C.is_blocking(C.FAIL) is True + assert C.is_blocking(C.ERROR) is True + assert C.is_blocking(C.WARN) is False + assert C.is_blocking(C.PENDING) is False + assert C.is_blocking(C.PASS) is False + + +# ── 检查项筛选 ────────────────────────────────────────────── +class _FakeVideoCheck(K.CleaningCheck): + slug = "_test.video" + label = "fake video" + requires_channels = ("rgb",) + # 多张卡片共享 —— video.* 就是这样,只要命中其一就跑 + device_cards = ("mono_rgb", "stereo_rgb", "rgbd_camera", "gripper_device") + + +class _FakeUmiCheck(K.CleaningCheck): + slug = "_test.umi" + label = "fake umi" + requires_channels = ("slam",) + requires_modality = ("gripper_device",) + device_cards = ("gripper_device",) + + +class _FakeCrossCheck(K.CleaningCheck): + slug = "_test.cross" + label = "fake cross" + cross_modal = True + + +def _with_checks(*classes, fn): + """临时注册一批检查项,跑完清理 —— 不污染全局注册表。""" + registered = [] + try: + for cls in classes: + K._registry[cls.slug] = cls() + registered.append(cls.slug) + return fn() + finally: + for slug in registered: + K._registry.pop(slug, None) + + +def test_applicable_filters_by_channel(): + def run(): + got = {c.slug for c in K.applicable_checks({"rgb"}, {"mono_rgb"})} + assert "_test.video" in got + assert "_test.umi" not in got # 缺 slam 信道 + _with_checks(_FakeVideoCheck, _FakeUmiCheck, fn=run) + + +def test_applicable_filters_by_modality(): + def run(): + # 有 slam 信道但设备不是 gripper_device → umi 检查不跑 + got = {c.slug for c in K.applicable_checks({"slam"}, {"mono_rgb"})} + assert "_test.umi" not in got + got = {c.slug for c in K.applicable_checks({"slam"}, {"gripper_device"})} + assert "_test.umi" in got + _with_checks(_FakeUmiCheck, fn=run) + + +def test_applicable_filters_by_device_cards(): + """设备卡片闸门 —— 光按信道筛不够。 + + 真实踩过的坑:手套项目(rgbd_camera)的批次里同样有一列 ``action`` + (采集端写的全零占位),于是 ``umi.action_present`` 满足 + ``requires_channels=("action",)`` 就跑了起来,给手套项目报了个 + "action 全零"的失败 —— 而对方根本不训练动作。 + + 声明了 device_cards 的检查项,必须只在该设备上跑。 + """ + def run(): + channels = {"action", "slam"} # 信道全够,只剩设备这一关 + # 设备是手套相机 → UMI 检查不跑,尽管信道满足了 + got = {c.slug for c in K.applicable_checks(channels, {"rgbd_camera"})} + assert "_test.umi" not in got + # 设备是 UMI 夹爪 → 跑 + got = {c.slug for c in K.applicable_checks(channels, {"gripper_device"})} + assert "_test.umi" in got + _with_checks(_FakeUmiCheck, fn=run) + + +def test_device_cards_gate_is_skipped_for_shared_checks(): + """声明了多张卡片的检查项,只要命中其一就跑(video.* 就是这种)。""" + def run(): + got = {c.slug for c in K.applicable_checks({"rgb"}, {"stereo_rgb"})} + assert "_test.video" in got + _with_checks(_FakeVideoCheck, fn=run) + + +def test_cross_modal_requires_two_modalities(): + def run(): + # 单模态 → 跨模态检查不跑 + got = {c.slug for c in K.applicable_checks(set(), {"mono_rgb"})} + assert "_test.cross" not in got + # 显式声明跨模态成立 → 跑 + got = {c.slug for c in K.applicable_checks( + set(), {"mono_rgb", "glove_sensor"}, cross_modal_active=True)} + assert "_test.cross" in got + _with_checks(_FakeCrossCheck, fn=run) + + +def test_applicable_respects_enabled_and_disabled(): + def run(): + got = {c.slug for c in K.applicable_checks( + {"rgb"}, {"mono_rgb"}, disabled={"_test.video"})} + assert "_test.video" not in got + got = {c.slug for c in K.applicable_checks( + {"rgb"}, {"mono_rgb"}, enabled={"_test.video"})} + assert got == {"_test.video"} + _with_checks(_FakeVideoCheck, fn=run) + + +def test_all_checks_is_sorted_by_slug(): + """排序稳定 —— ruleset_revision 是对它取哈希,顺序变了指纹就变。""" + slugs = [c.slug for c in K.all_checks()] + assert slugs == sorted(slugs) + + +def test_register_rejects_duplicate_slug(): + class First(K.CleaningCheck): + slug = "_test.dupe" + + class Second(K.CleaningCheck): + slug = "_test.dupe" + + try: + K.register_check(First) + try: + K.register_check(Second) + except ValueError as exc: + assert "_test.dupe" in str(exc) + else: + raise AssertionError("重复 slug 应当抛 ValueError") + finally: + K._registry.pop("_test.dupe", None) + + +def test_register_rejects_empty_slug(): + class NoSlug(K.CleaningCheck): + slug = "" + + try: + K.register_check(NoSlug) + except ValueError: + pass + else: + raise AssertionError("空 slug 应当抛 ValueError") + + +# ── 设备卡片分组(设置面板的 tab)──────────────────────────── +def test_every_check_declares_device_cards(): + """除跨设备检查外,每项都必须声明属于哪些设备卡片。 + + 否则它在设置面板里【无处可去】—— 用户看不到、也改不了它的阈值。 + 跨设备检查(cross_modal=True)约定不绑单卡片,只在"跨设备" tab 出现。 + """ + for check in K.all_checks(): + if check.cross_modal: + continue + assert check.device_cards, f"{check.slug} 没有声明 device_cards" + + +def test_device_cards_are_known_modalities(): + """device_cards 的取值必须来自 contract 的设备模态表 —— 防止拼错字符串。 + + 拼错不会报错,只会让检查项在面板上凭空消失,很难发现。 + """ + known = set(C.DEVICE_MODALITIES) + for check in K.all_checks(): + for card in check.device_cards: + assert card in known, f"{check.slug} 声明了未知设备卡片 {card!r}" + + +def test_checks_for_device_cards_only_returns_requested(): + """未连接的设备不该出现在分组结果里。""" + grouped = K.checks_for_device_cards(["gripper_device"]) + assert set(grouped) == {"gripper_device"} + assert all(c.device_cards.count("gripper_device") for c in grouped["gripper_device"]) + + +def test_checks_for_device_cards_places_multi_card_checks(): + """一个检查项可以同时属于多张卡片(video.* 对每种带摄像头的设备都成立)。""" + grouped = K.checks_for_device_cards(["rgbd_camera", "gripper_device"]) + in_a = {c.slug for c in grouped["rgbd_camera"]} + in_b = {c.slug for c in grouped["gripper_device"]} + assert "video.frame_drop" in in_a and "video.frame_drop" in in_b + # UMI 专有的只出现在 gripper_device 下 + assert "umi.slam_continuity" in in_b and "umi.slam_continuity" not in in_a + + +def test_video_checks_cover_gripper_device(): + """UMI 夹爪自带鱼眼相机,视频检查对它同样成立。 + + 历史坑:video.* 早期只声明了四种摄像机模态,漏了 gripper_device —— + 结果 UMI 项目的设置面板里视频检查整组消失。 + """ + grouped = K.checks_for_device_cards(["gripper_device"]) + slugs = {c.slug for c in grouped["gripper_device"]} + assert {"video.frame_drop", "video.black_screen", + "video.freeze", "video.decode_error"} <= slugs + + +if __name__ == "__main__": + _failures = 0 + for _name, _fn in sorted(globals().items()): + if not _name.startswith("test_") or not callable(_fn): + continue + import inspect + try: + if "tmp_path" in inspect.signature(_fn).parameters: + import tempfile + with tempfile.TemporaryDirectory() as _tmp: + _fn(Path(_tmp)) + else: + _fn() + print(f"PASS {_name}") + except Exception as _exc: # noqa: BLE001 + _failures += 1 + print(f"FAIL {_name}: {_exc}") + raise SystemExit(1 if _failures else 0) diff --git a/processor/tests/test_cleaning_frame_alignment.py b/processor/tests/test_cleaning_frame_alignment.py new file mode 100644 index 0000000000000000000000000000000000000000..2596643cd7463c80e123e374e7f51244782dcffd --- /dev/null +++ b/processor/tests/test_cleaning_frame_alignment.py @@ -0,0 +1,125 @@ +"""帧对齐检查的回归测试(app/cleaning.py)。 + +背景:2026-09-23 之前 ``_check_frame_alignment_fs`` 直接用了 ``cv2`` 却没 import, +``NameError`` 被外层 ``except`` 吞掉 → 该检查在文件级路径下**永远静默通过**, +detail 写着 "Check error (skipped)" 而 passed=True,没人会注意。 + +本文件锁住三件事: + 1. 检查真的跑了(不再返回 Check error / Degraded pass) + 2. 没有视频时不误报 —— 纯传感器批次(如纯手套)是合法的,判"未适用" + 3. 帧数真的不匹配时能抓出来 + + pytest tests/test_cleaning_frame_alignment.py + python3 tests/test_cleaning_frame_alignment.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 + +from app.cleaning import _check_frame_alignment_fs # noqa: E402 + +CHECK = "frame_alignment" + + +def _write_parquet(batch: Path, frames: int) -> None: + """写一个带 frame_index 列的 data parquet(检查按 rglob('data') 找)。""" + data_dir = batch / "data" / "chunk-000" + data_dir.mkdir(parents=True, exist_ok=True) + pd.DataFrame({"frame_index": range(frames)}).to_parquet( + data_dir / "episode_000000.parquet") + + +def _write_video(batch: Path, frames: int, name: str = "cam.mp4") -> None: + """写一个真 mp4,帧数可控。""" + import cv2 + path = batch / name + path.parent.mkdir(parents=True, exist_ok=True) + writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), 30, (64, 48)) + try: + for index in range(frames): + writer.write(np.full((48, 64, 3), index % 256, dtype=np.uint8)) + finally: + writer.release() + + +# ── 不再静默通过 ──────────────────────────────────────────── +def test_check_actually_runs_without_video(tmp_path): + """没有视频时必须给出明确结论,而不是被异常吞掉的伪通过。""" + _write_parquet(tmp_path, 100) + result = _check_frame_alignment_fs(tmp_path) + assert result["name"] == CHECK + # 修复前的症状:detail 里写 "Check error (skipped)" + assert "Check error" not in result["detail"] + assert "Degraded pass" not in result["detail"] + + +def test_no_video_is_not_a_failure(tmp_path): + """纯传感器批次(只有手套、没有相机)是合法的 —— 没有可比对的对象。""" + _write_parquet(tmp_path, 100) + result = _check_frame_alignment_fs(tmp_path) + assert result["passed"] is True + assert "No video" in result["detail"] + + +def test_no_parquet_is_skipped(tmp_path): + result = _check_frame_alignment_fs(tmp_path) + assert result["passed"] is True + assert "No parquet" in result["detail"] + + +# ── 真的能抓出不匹配 ──────────────────────────────────────── +def test_matching_counts_pass(tmp_path): + _write_parquet(tmp_path, 50) + _write_video(tmp_path, 50) + result = _check_frame_alignment_fs(tmp_path) + assert result["passed"] is True, result["detail"] + + +def test_short_video_fails(tmp_path): + """视频只有 10 帧、数据有 100 帧 → 必须判失败(容差 10%)。""" + _write_parquet(tmp_path, 100) + _write_video(tmp_path, 10) + result = _check_frame_alignment_fs(tmp_path) + assert result["passed"] is False, result["detail"] + + +def test_within_tolerance_passes(tmp_path): + """差 5% 在 ±10% 容差内 → 通过。""" + _write_parquet(tmp_path, 100) + _write_video(tmp_path, 95) + result = _check_frame_alignment_fs(tmp_path) + assert result["passed"] is True, result["detail"] + + +def test_result_shape_is_stable(tmp_path): + """返回结构必须与本模块另外两个检查一致(前端按 name/passed/detail 读)。""" + _write_parquet(tmp_path, 30) + _write_video(tmp_path, 30) + result = _check_frame_alignment_fs(tmp_path) + assert set(result) == {"name", "passed", "detail"} + assert isinstance(result["passed"], bool) + + +if __name__ == "__main__": + import inspect + import tempfile + + failures = 0 + for name, fn in sorted(globals().items()): + if not name.startswith("test_") or not callable(fn): + continue + try: + if "tmp_path" in inspect.signature(fn).parameters: + with tempfile.TemporaryDirectory() as tmp: + fn(Path(tmp)) + else: + fn() + print(f"PASS {name}") + except Exception as exc: # noqa: BLE001 + failures += 1 + print(f"FAIL {name}: {exc}") + raise SystemExit(1 if failures else 0) diff --git a/processor/tests/test_cleaning_gate.py b/processor/tests/test_cleaning_gate.py new file mode 100644 index 0000000000000000000000000000000000000000..26374e53813e4c6b2833fc0d129f3aba33c18c8e --- /dev/null +++ b/processor/tests/test_cleaning_gate.py @@ -0,0 +1,183 @@ +"""「数据不对 → 卡在人工审核」的行为锁。 + +盯住三件事,每一件坏掉都是**静默**的(不报错、流程照常走完): + + 1. 判 FAIL/ERROR 的批次被推回 ``to_review``,且清掉 ``approved_at`` + 2. WARN 只记录、不拦 —— 否则警告阈值一响,整批数据全卡住 + 3. **自动批准路径主动让路** —— AI 标注 / 视频门禁各看各的报告,不看质检 + 结论;不查那个粘性标记的话,它们会在质检推回 to_review 之后又把状态 + 置成 reviewed,坏数据照样被放行 + +全部在临时存储根上跑(monkeypatch ``localstore`` 的路径常量),不碰真实数据。 + + pytest tests/test_cleaning_gate.py + python3 tests/test_cleaning_gate.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # noqa: E402 + +import pytest # noqa: E402 + +from app import localstore # noqa: E402 +from app.processing.cleaning import store # noqa: E402 +from app.processing.cleaning import contract as C # noqa: E402 + + +@pytest.fixture +def sandbox(tmp_path, monkeypatch): + """把 episode state 落到临时目录 —— 别碰 ~/.cache/egodata-data。""" + states = tmp_path / "state" / "episode_states" + states.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(localstore, "STATE_ROOT", tmp_path / "state") + monkeypatch.setattr(localstore, "EPISODE_STATES_DIR", states) + localstore._episode_state_cache.clear() + yield states + localstore._episode_state_cache.clear() + + +def _report(status, *, ranges=0): + """造一份最小报告 —— 只关心 ``episode.status``,那是拦截判据。""" + return { + "schema_version": 2, + "episode_id": "E1", + "computed_at": "2026-09-24T00:00:00+00:00", + "passed": status == C.PASS, + "streams": [], + "ranges": [{}] * ranges, + "episode": { + "status": status, + "status_label": C.SEVERITY_LABELS.get(status, status), + "failed_streams": [], "warn_streams": [], "pending_streams": [], + "summary": "test", + }, + "findings": [], + } + + +def _seed(states, status="to_review", approved_at=None): + import json + eid = "E1" + (states / f"{eid}.json").write_text(json.dumps({ + "id": eid, "name": eid, "project": "P", "status": status, + "approved_at": approved_at, + }, ensure_ascii=False), encoding="utf-8") + localstore._episode_state_cache.pop(eid, None) + return eid + + +# ── 拦截 ──────────────────────────────────────────────────── +@pytest.mark.parametrize("status", [C.FAIL, C.ERROR]) +def test_failure_pushes_back_to_review(sandbox, status): + eid = _seed(sandbox, status="reviewed", approved_at="2026-01-01T00:00:00Z") + assert store.save_report(eid, _report(status)) is True + + state = localstore.read_episode_state(eid) + assert state["status"] == "to_review" # 自动批准被撤销 + assert state["approved_at"] is None # 批准时间一起清掉 + assert store.is_approval_blocked(state) is True + + +@pytest.mark.parametrize("status", [C.WARN, C.PASS, C.PENDING]) +def test_non_failure_does_not_block(sandbox, status): + """WARN/PENDING 只是警告 —— 拦的话整批数据都会卡住。""" + eid = _seed(sandbox, status="reviewed") + store.save_report(eid, _report(status)) + + state = localstore.read_episode_state(eid) + assert state["status"] == "reviewed" # 状态没被动过 + assert store.is_approval_blocked(state) is False + + +def test_does_not_resurrect_deleted_episode(sandbox): + """质检不能把用户删掉/否决过的批次复活。""" + for status in ("deleted", "rejected"): + eid = _seed(sandbox, status=status) + store.save_report(eid, _report(C.FAIL)) + assert localstore.read_episode_state(eid)["status"] == status + + +def test_report_still_written_when_not_blocking(sandbox): + """不拦也得把报告写进去 —— 拦截不是写报告的前提。""" + eid = _seed(sandbox) + store.save_report(eid, _report(C.WARN, ranges=3)) + summary = store.load_summary(eid) + assert summary["status"] == C.WARN and summary["ranges"] == 3 + + +# ── 自动批准让路 ──────────────────────────────────────────── +def _quality_gate(state): + """转调 ai_annotation 的真实写入路径,避免测试复制一份逻辑。""" + from app.ai_annotation import _set_ai_quality_state + return _set_ai_quality_state + + +def test_ai_gate_refuses_to_auto_approve_when_blocked(sandbox): + """★ 核心用例:质检判 FAIL 后,AI 标注门禁即使自己通过也不得自动批准。""" + eid = _seed(sandbox, status="to_review") + store.save_report(eid, _report(C.FAIL)) + + _quality_gate(None)(eid, {"passed": True}, auto_approve=True) + + state = localstore.read_episode_state(eid) + assert state["status"] == "to_review", "坏数据被 AI 门禁放行了" + assert state["ai_quality_status"] == "passed" # 它自己的结论照常记录 + + +def test_ai_gate_still_auto_approves_when_not_blocked(sandbox): + """别把拦截修成"自动批准彻底失效" —— 干净数据该自动过。""" + eid = _seed(sandbox, status="to_review") + store.save_report(eid, _report(C.PASS)) + + _quality_gate(None)(eid, {"passed": True}, auto_approve=True) + + assert localstore.read_episode_state(eid)["status"] == "reviewed" + + +def test_video_gate_refuses_to_auto_approve_when_blocked(sandbox): + """视频干净 ≠ 数据干净 —— 视频门禁同样要让路。""" + from app.video_quality import _apply_video_quality_result + + eid = _seed(sandbox, status="to_review") + store.save_report(eid, _report(C.FAIL)) + + _apply_video_quality_result(eid, {"passed": True}) + + state = localstore.read_episode_state(eid) + assert state["status"] == "to_review", "坏数据被视频门禁放行了" + assert state["video_quality_status"] == "passed" + + +# ── 标记的粘性 ────────────────────────────────────────────── +def test_block_clears_once_data_is_fixed(sandbox): + """数据修好后重跑要能自动批准 —— 否则卡住就再也出不来了。 + + 注意 ``save_report`` **只拦不批**:它解封后不会自己把状态改回 reviewed, + 批准是 AI/视频门禁或人工的事。所以这里走一遍真实的"修好 → 重跑"顺序: + worker 先置 to_review,质检复核(清除标记),随后门禁自动批准。 + """ + eid = _seed(sandbox, status="to_review") + + store.save_report(eid, _report(C.FAIL)) + assert store.is_approval_blocked(localstore.read_episode_state(eid)) is True + + # —— 重跑 —— + localstore.set_episode_status(eid, "to_review") # worker 完成回调做的 + store.save_report(eid, _report(C.PASS)) # 质检复核:数据已修好 + state = localstore.read_episode_state(eid) + assert store.is_approval_blocked(state) is False + + _quality_gate(None)(eid, {"passed": True}, auto_approve=True) + assert localstore.read_episode_state(eid)["status"] == "reviewed" + + +def test_save_report_never_approves(sandbox): + """质检**只拦不批** —— 它不该越过门禁自己把批次标成通过。""" + eid = _seed(sandbox, status="to_review") + store.save_report(eid, _report(C.PASS)) + assert localstore.read_episode_state(eid)["status"] == "to_review" + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/processor/tests/test_cleaning_glove.py b/processor/tests/test_cleaning_glove.py new file mode 100644 index 0000000000000000000000000000000000000000..d9d89d1f2ed06b9276a8a714cb7e22a5260df10a --- /dev/null +++ b/processor/tests/test_cleaning_glove.py @@ -0,0 +1,202 @@ +"""手套检查项 —— 每一项都必须**被证明能抓到失败**。 + +真实数据上 5 项全绿(6 集、左右手各 5-6 条流、零误报)。但"全绿"证明不了什么: +**检查项从来没触发过,和检查项根本跑不起来,报告长得一模一样。** 所以这里每条 +都构造一份明确的坏数据,断言它被抓到;再造一份好数据,断言不误报。 + +阈值全部来自实测: + * 阵列 16×16 = 256 维 + * 取值门限 500(0 到 500 之间没有值) + * 设备更新 ~7.5Hz → 受压时静止**恰好 4 帧**(正常,不是卡死) + * 整集最低有 11.4% 的帧有压力 + + pytest tests/test_cleaning_glove.py + python3 tests/test_cleaning_glove.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # noqa: E402 + +from app.processing.cleaning.checks import ( # noqa: E402 + CheckContext, StreamEvidence, applicable_checks, get_check, + PASS, WARN, FAIL, +) + +SIZE = 256 + + +# ── 构造 ──────────────────────────────────────────────────── +def _frame(value: float = 0.0, size: int = SIZE) -> tuple: + return tuple([value] * size) + + +def _context(series: dict, *, channel="tactile", key="left_glove", + fps=30.0) -> CheckContext: + stream = StreamEvidence(key=key, channel=channel, series=series) + return CheckContext(batch_dir=Path("/nonexistent"), fps=fps, streams=(stream,)) + + +def _run(slug: str, ctx: CheckContext, params: dict | None = None): + ctx.params = params or {} + findings = get_check(slug).run(ctx) + assert findings, f"{slug} 一条结论都没产出 —— 报告会显示为『没问题』" + return findings + + +def _only(slug, ctx, params=None): + findings = _run(slug, ctx, params) + statuses = {f.status for f in findings} + assert len(statuses) == 1, f"期望单一结论,得到 {statuses}" + return findings[0] + + +# ── 可达性:检查项必须真的会被选中 ─────────────────────────── +def test_glove_checks_are_applicable(): + """★ 防"检查项静默不跑"—— 声明与闸门对不上时它们一条都不会执行。""" + slugs = {c.slug for c in applicable_checks( + channels={"tactile", "device_status", "time"}, + modalities={"glove_sensor"})} + for expected in ("glove.array_shape", "glove.device_status", + "glove.frozen_array", "glove.gate_floor", + "glove.no_contact"): + assert expected in slugs, f"{expected} 在手套数据上不可达" + + +def test_glove_checks_skip_other_devices(): + """UMI 夹爪上不该跑手套检查 —— 设备卡片闸门要生效。""" + slugs = {c.slug for c in applicable_checks( + channels={"tactile", "slam", "time"}, modalities={"gripper_device"})} + assert not any(s.startswith("glove.") for s in slugs) + + +def test_device_status_check_needs_its_channel(): + """没有状态列(老批次)时不该跑 —— 跑起来只会报"没数据"。""" + slugs = {c.slug for c in applicable_checks( + channels={"tactile", "time"}, modalities={"glove_sensor"})} + assert "glove.device_status" not in slugs + assert "glove.frozen_array" in slugs + + +# ── 1. 连接状态 ───────────────────────────────────────────── +def test_status_constant_connected_passes(): + ctx = _context({"device_status": tuple(["connected"] * 100)}, channel="device_status") + assert _only("glove.device_status", ctx).status is PASS + + +def test_status_change_mid_recording_fails(): + """★ 从"连着"变成别的 = 掉线,与状态词表无关,见到即拦。""" + values = tuple(["connected"] * 50 + ["disconnected"] * 50) + ctx = _context({"device_status": values}, channel="device_status") + finding = _only("glove.device_status", ctx) + assert finding.status is FAIL + assert finding.metrics["first_change_frame"] == 50 + + +def test_status_unknown_constant_only_warns(): + """词表只见过 connected;别的取值不拦批次(拦错了比不拦更糟)。""" + ctx = _context({"device_status": tuple(["ok"] * 100)}, channel="device_status") + assert _only("glove.device_status", ctx).status is WARN + + +def test_status_all_blank_warns(): + ctx = _context({"device_status": tuple([""] * 100)}, channel="device_status") + assert _only("glove.device_status", ctx).status is WARN + + +# ── 2. 阵列完整性 ─────────────────────────────────────────── +def test_shape_full_array_passes(): + ctx = _context({"tactile": tuple([_frame()] * 50)}) + assert _only("glove.array_shape", ctx).status is PASS + + +def test_shape_catches_truncation(): + """★ 正是那个真实踩过的 bug:256 维被静默截成 3 维。""" + ctx = _context({"tactile": tuple([_frame(size=3)] * 500)}) + finding = _only("glove.array_shape", ctx) + assert finding.status is FAIL + assert finding.metrics["bad_ratio"] == 1.0 + assert finding.metrics["observed_sizes"] == [3] + + +def test_shape_single_null_frame_only_warns(): + """偶发一帧缺失不该拦住整批。""" + values = [_frame(600.0)] * 999 + [()] + ctx = _context({"tactile": tuple(values)}) + assert _only("glove.array_shape", ctx).status is WARN + + +# ── 3. 取值下限 ───────────────────────────────────────────── +def test_gate_floor_normal_values_pass(): + ctx = _context({"tactile": (_frame(0.0), _frame(508.09), _frame(1500.0))}) + assert _only("glove.gate_floor", ctx).status is PASS + + +def test_gate_floor_catches_encoding_change(): + """★ 固件改成直接输出原始 ADC 时,值会掉到门限以下 —— 而别的检查全绿。""" + values = [_frame(v) for v in (5.0, 20.0, 137.0, 400.0)] + [_frame(900.0)] + ctx = _context({"tactile": tuple(values)}) + finding = _only("glove.gate_floor", ctx) + assert finding.status is FAIL + assert finding.metrics["below_ratio"] == 0.8 + + +def test_gate_floor_all_zero_is_not_its_business(): + """整集全零是 glove.no_contact 的事,不在这里重复报。""" + findings = get_check("glove.gate_floor").run(_context({"tactile": (_frame(),)})) + assert findings == [] + + +# ── 4. 阵列卡死 ───────────────────────────────────────────── +def test_frozen_normal_device_rate_passes(): + """★ 设备本来就每 4 帧才更新一次 —— 静止 4 帧是**正常**,不是卡死。""" + values = [] + for block in range(100): + values.extend([_frame(600.0 + block)] * 4) + ctx = _context({"tactile": tuple(values)}) + assert _only("glove.frozen_array", ctx).status is PASS + + +def test_frozen_detects_stall(): + values = [_frame(600.0)] * 500 + [_frame(900.0)] * 10 + ctx = _context({"tactile": tuple(values)}) + finding = _only("glove.frozen_array", ctx) + assert finding.status is FAIL # 500 帧 ≥ fail_frames(300) + assert finding.metrics["longest_run_frames"] == 500 + # 区间要能定位到哪儿卡了 + assert finding.ranges and finding.ranges[0].start_frame == 0 + + +def test_frozen_ignores_all_zero_stretches(): + """★ 实测踩过的坑:全零段天然"完全相同",算进去会报出大片假区间。 + + 真实数据里一集有 88% 的帧全零,最长"静止"575 帧 —— 全部来自全零段。 + """ + values = [_frame(0.0)] * 800 + [_frame(600.0)] * 10 + ctx = _context({"tactile": tuple(values)}) + assert _only("glove.frozen_array", ctx).status is PASS + + +def test_frozen_warn_threshold_between_normal_and_stall(): + """40~300 帧是警告区(正常 4 帧,卡死 300+)。""" + values = [_frame(600.0)] * 100 + [_frame(700.0)] * 10 + ctx = _context({"tactile": tuple(values)}) + assert _only("glove.frozen_array", ctx).status is WARN + + +# ── 5. 无接触 ─────────────────────────────────────────────── +def test_no_contact_with_pressure_passes(): + ctx = _context({"tactile": (_frame(0.0), _frame(620.0))}) + assert _only("glove.no_contact", ctx).status is PASS + + +def test_no_contact_all_zero_warns(): + ctx = _context({"tactile": tuple([_frame(0.0)] * 500)}) + finding = _only("glove.no_contact", ctx) + assert finding.status is WARN + assert finding.metrics["active_frames"] == 0 + + +if __name__ == "__main__": + import pytest + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/processor/tests/test_cleaning_pose_validity.py b/processor/tests/test_cleaning_pose_validity.py new file mode 100644 index 0000000000000000000000000000000000000000..0199f25ab8455fdfa6d5c482e88c22bc3144d9ee --- /dev/null +++ b/processor/tests/test_cleaning_pose_validity.py @@ -0,0 +1,126 @@ +"""全零占位位姿 —— 一个把整集假判成 FAIL 的 bug 的回归锁。 + +bug 长什么样 +------------ +``observation.slam_pose`` 里没采到的帧写的是**全零占位** ``(0,0,0,0,0,0,0)``。 +它长得完全像合法位姿:长度 7、能索引、能相减。但它的四元数模是 0,于是 + + 两个零四元数的点积 = 0 + acos(0) = π/2 → 夹角 = 2 × π/2 = **π = 180°** + 30fps 下角速度 = π × 30 = **94.248 rad/s** + +**任何涉及零占位的相邻对都会报一次"180° 跳变"** —— 不只是"真实↔零"的过渡, +两个零之间也一样。 + +实测 Test94 的 slam_pose 有 437/599 行是全零,SLAM 连续性因此报出 **465 处** +"跳变"(599 帧里几乎每一帧),整集被判 FAIL → **拦回人工审核**。而峰值角速度 +94.248 就是上面那个 π×30,是识别这个 bug 的指纹。 + + pytest tests/test_cleaning_pose_validity.py + python3 tests/test_cleaning_pose_validity.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # noqa: E402 + +from app.processing.cleaning import collect # noqa: E402 +from app.processing.cleaning.checks import ( # noqa: E402 + CheckContext, StreamEvidence, get_check, PASS, WARN, FAIL, +) +from app.processing.cleaning.contract import pose_is_valid # noqa: E402 + +# π × 30fps —— 零四元数产生的"每帧 180°"角速度 +ZERO_QUAT_RPS = 94.248 + +# 一个模不为 0 的合法朝向 +VALID = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0) +ZERO = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +def _context(poses, fps=30.0): + stream = StreamEvidence(key="slam_pose", channel="slam", fps=fps, + series={"pose": tuple(poses)}) + return CheckContext(batch_dir=Path("/nonexistent"), fps=fps, streams=(stream,)) + + +def _continuity(poses, fps=30.0): + ctx = _context(poses, fps) + ctx.params = {} + findings = get_check("umi.slam_continuity").run(ctx) + assert findings, "一条结论都没产出" + return findings[0] + + +# ── 判据本身 ──────────────────────────────────────────────── +def test_zero_quaternion_is_invalid(): + assert pose_is_valid(VALID) is True + assert pose_is_valid(ZERO) is False # ← 核心 + assert pose_is_valid(()) is False # 空元组本来就不算 + assert pose_is_valid((1.0, 2.0, 3.0)) is False + + +def test_zero_position_is_still_a_valid_pose(): + """位置可以合法地是原点;只有四元数模为 0 才不是合法朝向。""" + assert pose_is_valid((0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)) is True + + +# ── 核心回归 ──────────────────────────────────────────────── +def test_zero_placeholders_do_not_create_jumps(): + """★ 全是零占位的一集不该报任何跳变。""" + finding = _continuity([ZERO] * 500) + assert finding.status is PASS, f"零占位被当成了跳变:{finding.message}" + assert finding.metrics["jumps"] == 0 + + +def test_mixed_valid_and_zero_placeholders_do_not_create_jumps(): + """★ 真实↔零占位的过渡同样不该报 —— 这正是 Test94 的情况。""" + poses = [] + for index in range(200): + poses.append(VALID if index % 3 == 0 else ZERO) + finding = _continuity(poses) + assert finding.status is PASS, f"混合占位报出 {finding.metrics['jumps']} 处跳变" + assert finding.metrics["jumps"] == 0 + + +def test_zero_placeholder_would_have_produced_the_94_rps_signature(): + """把守卫去掉,峰值角速度应当落回 94.248 —— 确认这确实是那个 bug。""" + finding = _continuity([ZERO] * 100) + assert finding.metrics["peak_angular_rps"] < ZERO_QUAT_RPS / 10 + + +# ── 真阳性不能被一起修掉 ───────────────────────────────────── +def test_real_relocation_jump_is_still_detected(): + """两帧合法位姿之间突然位移 → 仍要报出来。""" + near = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0) + far = (10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0) # 10 米瞬移 + finding = _continuity([near] * 50 + [far] * 50) + assert finding.status is WARN + assert finding.metrics["jumps"] == 1 + assert finding.metrics["peak_linear_mps"] > 100 # 10m / (1/30s) = 300 m/s + + +def test_many_real_jumps_still_fail(): + near = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0) + far = (5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0) + poses = [] + for _ in range(30): + poses.extend([near, far]) + finding = _continuity(poses) + assert finding.status is FAIL + assert finding.metrics["jumps"] >= 10 + + +# ── 采集层的 valid_count 也要认这个 ───────────────────────── +def test_collector_valid_count_excludes_zero_placeholders(): + """位姿流的 valid_count 若把零占位算作有效,别的检查也会被带偏。""" + values = [list(VALID), list(ZERO), None, list(VALID)] + counts = tuple( + 1 if collect.pose_is_valid(tuple(item)) else 0 + for item in collect.vector_series(values, 7)) + assert counts == (1, 0, 0, 1) + + +if __name__ == "__main__": + import pytest + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/processor/tests/test_cleaning_report.py b/processor/tests/test_cleaning_report.py new file mode 100644 index 0000000000000000000000000000000000000000..ac17fc44c46e24fae8dc59f805df81d95520b224 --- /dev/null +++ b/processor/tests/test_cleaning_report.py @@ -0,0 +1,194 @@ +"""清洗报告组装单测(app/processing/cleaning/report.py)。 + +覆盖:四层结果的每一层、时间轴拍平与去重、训练用途判定、fail-closed 语义。 + +全是纯函数 —— 不需要真视频、不需要 cv2、不需要任何 mock。 + + pytest tests/test_cleaning_report.py + python3 tests/test_cleaning_report.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.processing.cleaning import contract as C # noqa: E402 +from app.processing.cleaning import report as R # noqa: E402 + + +def _range(start, end, code="video.freeze", severity=C.WARN, stream="s1"): + return C.Range(start_sec=start, end_sec=end, code=code, + severity=severity, stream=stream) + + +def _stream(name="s1", status=C.PASS, ranges=(), channels=("rgb", "time")): + return C.StreamResult(stream=name, status=status, ranges=tuple(ranges), + channels=tuple(channels)) + + +# ── 第二层:时间轴 ─────────────────────────────────────────── +def test_timeline_sorted_by_time(): + streams = [ + _stream("s1", ranges=[_range(5, 6), _range(1, 2)]), + _stream("s2", ranges=[_range(3, 4)]), + ] + axis = R.build_timeline(streams) + assert [row["start_sec"] for row in axis] == [1, 3, 5] + + +def test_timeline_dedupes_identical_rows(): + dup = _range(1, 2) + axis = R.build_timeline([_stream("s1", ranges=[dup, dup])]) + assert len(axis) == 1 + + +def test_timeline_fills_stream_from_parent(): + """range 自己没写 stream 时,从所属的 stream 补上。""" + bare = C.Range(start_sec=1, end_sec=2, code="x", severity=C.WARN) + axis = R.build_timeline([_stream("cam_a", ranges=[bare])]) + assert axis[0]["stream"] == "cam_a" + + +def test_timeline_accepts_dict_streams(): + """报告从磁盘读回来时 streams 是 dict,不是 StreamResult。""" + axis = R.build_timeline([{ + "stream": "s1", + "ranges": [{"start_sec": 1, "end_sec": 2, "code": "x", "severity": C.WARN}], + }]) + assert axis[0]["code"] == "x" + + +def test_timeline_empty(): + assert R.build_timeline([]) == [] + + +# ── 第三层:整集结论 ───────────────────────────────────────── +def test_episode_takes_worst_stream(): + episode = R.rollup_episode([_stream("a", C.PASS), _stream("b", C.FAIL)]) + assert episode["status"] == C.FAIL + assert episode["failed_streams"] == ["b"] + + +def test_episode_includes_findings_in_rollup(): + """跨模态检查没有对应的 stream,它的结论也要参与整集判定。""" + finding = C.Finding(rule="cross.sync", version="1.0", status=C.FAIL, + scope="cross_modal") + episode = R.rollup_episode([_stream("a", C.PASS)], [finding]) + assert episode["status"] == C.FAIL + + +def test_episode_all_pass(): + episode = R.rollup_episode([_stream("a", C.PASS), _stream("b", C.PASS)]) + assert episode["status"] == C.PASS + assert episode["summary"] == "All checks passed" + + +def test_episode_summary_counts_each_bucket(): + episode = R.rollup_episode([ + _stream("a", C.FAIL), _stream("b", C.WARN), _stream("c", C.PENDING), + ]) + assert "1 stream(s) failed" in episode["summary"] + assert "1 stream(s) with warnings" in episode["summary"] + assert "1 stream(s) pending review" in episode["summary"] + + +def test_episode_empty_streams_is_pass(): + """没有流可检查 → 这一层判 PASS(是否 fail-closed 由 build_report/passed 决定)。""" + assert R.rollup_episode([])["status"] == C.PASS + + +# ── 第四层:训练用途 ───────────────────────────────────────── +def test_training_use_fails_on_missing_channel(): + rows = {row["use"]: row for row in R.rollup_training_use({"rgb", "time"})} + assert rows["pure_vision"]["status"] == C.PASS + assert rows["vision_tactile_joint"]["status"] == C.FAIL + assert "not collected" in rows["vision_tactile_joint"]["reason"] + assert rows["vision_tactile_joint"]["missing"] == ["tactile"] + + +def test_training_use_reports_all_missing_channels(): + rows = {row["use"]: row for row in R.rollup_training_use(set())} + assert set(rows["pure_vision"]["missing"]) == {"rgb", "time"} + + +def test_training_use_inherits_episode_status(): + """信道齐全但整集有问题 → 该用途跟着判 warn/fail,而不是 pass。""" + rows = {row["use"]: row for row in R.rollup_training_use({"rgb", "time"}, C.WARN)} + assert rows["pure_vision"]["status"] == C.WARN + assert rows["pure_vision"]["reason"] == "data quality problem" + + +def test_training_use_covers_every_declared_rule(): + rows = R.rollup_training_use({"rgb", "time"}) + assert len(rows) == len(R.TRAINING_USE_RULES) + + +# ── 完整报告 ──────────────────────────────────────────────── +def test_build_report_shape(): + rep = R.build_report( + [_stream("cam_a", C.WARN, [_range(1, 2)])], + [], + episode_id="ep1", project="proj", + modalities={"actual": ["mono_rgb"]}, + channels={"rgb", "time"}, + ruleset_revision="abc123", + template_source="workflow", + ) + assert rep["schema_version"] == R.SCHEMA_VERSION + assert rep["episode_id"] == "ep1" + assert rep["ruleset_revision"] == "abc123" + assert rep["template_source"] == "workflow" + assert rep["episode"]["status"] == C.WARN + assert rep["passed"] is False # 只有 PASS 算通过,WARN 不算 + assert len(rep["ranges"]) == 1 + assert len(rep["streams"]) == 1 + assert rep["training_use"] + + +def test_build_report_passed_only_when_all_clean(): + rep = R.build_report([_stream("a", C.PASS)], [], channels={"rgb", "time"}) + assert rep["episode"]["status"] == C.PASS + assert rep["passed"] is True + + +def test_warn_is_not_passed(): + """WARN 不算通过 —— 这是"卡在人工审核"的判定依据,必须显式锁住。""" + assert R.passed_for(C.WARN) is False + assert R.passed_for(C.PASS) is True + + +def test_build_report_pulls_channels_from_streams(): + """没显式传 channels 时,从各条流自己声明的信道汇总。""" + rep = R.build_report([_stream("a", C.PASS, channels=("rgb", "time", "tactile"))]) + rows = {row["use"]: row for row in rep["training_use"]} + assert rows["vision_tactile_joint"]["status"] == C.PASS + + +def test_empty_report_is_fail_closed(): + """没有数据 ≠ 数据合格。""" + rep = R.empty_report(episode_id="ep1", reason="no_data") + assert rep["passed"] is False + assert rep["episode"]["status"] == C.ERROR + assert rep["reason"] == "no_data" + assert rep["streams"] == [] + + +if __name__ == "__main__": + import inspect + import tempfile + + _failures = 0 + for _name, _fn in sorted(globals().items()): + if not _name.startswith("test_") or not callable(_fn): + continue + try: + if "tmp_path" in inspect.signature(_fn).parameters: + with tempfile.TemporaryDirectory() as _tmp: + _fn(Path(_tmp)) + else: + _fn() + print(f"PASS {_name}") + except Exception as _exc: # noqa: BLE001 + _failures += 1 + print(f"FAIL {_name}: {_exc}") + raise SystemExit(1 if _failures else 0) diff --git a/processor/web/VERSION b/processor/web/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..3291e5b98f77b0606e11368fd8e073607e29aba3 --- /dev/null +++ b/processor/web/VERSION @@ -0,0 +1,29 @@ +1.12.4 + +# 前端版本 —— 覆盖 processor/web/ 下的**全部**前端代码: +# +# templates/ Jinja2 页面(含 base 布局与侧边栏) +# static/js/ 手写脚本(老的是经典脚本;train/ 与 inference/ 是 ES module) +# static/*.js|css 第三方 vendored 资源与构建产物 +# workflow-studio/ React SPA 源码 +# +# ── 一个号,两个用途 ────────────────────────────────────────────── +# +# 1. 模板里的 `?v={{ asset_version }}` 缓存键 —— 读了它,改了 JS/CSS 浏览器 +# 才会重新拉 +# 2. 提交信息里的「前端 X」 +# +# ★ **改前端只 bump 这一个文件。** 两个用途共用一个数,不会出现"提交信息写 +# 1.12.3、但浏览器拿的是 1.12.2"这种对不上的情况。 +# +# ── 和另外两个号的关系 ─────────────────────────────────────────── +# +# 后端号 processor/app/version.py —— 只管后端(**不再是**缓存键) +# SPA 号 workflow-studio/package.json —— 只代表那个 React SPA +# 三个号互相独立,不必同步。 +# +# ── 改完要重启 ─────────────────────────────────────────────────── +# +# app/web_version.py 在服务启动时读一次就缓存住(不是每请求读盘),所以 +# bump 完要重启服务才生效。想让它立刻生效得改成可调用对象、模板写 +# `{{ asset_version() }}` —— 那要改 22 处模板,不划算。 diff --git a/processor/web/static/iconify-preload.js b/processor/web/static/iconify-preload.js index 1bdf794b2149c68b4c65b58d9835a3695d405622..c8d816e463c0be3211bd2ad1be7ad921b25e8055 100644 --- a/processor/web/static/iconify-preload.js +++ b/processor/web/static/iconify-preload.js @@ -73,5 +73,6 @@ window.IconifyPreload.push({ 'logout-outlined': { body: '' }, 'thunderbolt-outlined': { body: '' }, 'menu-outlined': { body: '' }, + 'info-circle-outlined': { body: '' }, } }); diff --git a/processor/web/static/js/annotations.js b/processor/web/static/js/annotations.js index fdee2e7c5ac2cd0538021daf5ab0a57330dc34db..7b3488607bac21d00528d095601f267e7e56b319 100644 --- a/processor/web/static/js/annotations.js +++ b/processor/web/static/js/annotations.js @@ -475,10 +475,10 @@ async function aiAnnotate() { try { st = await (await fetch(`/api/v1/episode/${currentEpisodeId}/ai-annotate/status`)).json(); } catch (_) { /* keep polling */ } - if (st.status === 'signal_segmenting') setLabel(' 切段中…'); - else if (st.status === 'vlm_analyzing') setLabel(' VLM 分析中…'); - else if (st.status === 'writing') setLabel(' 写入…'); - else if (st.status === 'exporting') setLabel(' 写入数据集…'); + if (st.status === 'signal_segmenting') setLabel(' ' + t('ai_status_segmenting') + ''); + else if (st.status === 'vlm_analyzing') setLabel(' ' + t('ai_status_analyzing') + ''); + else if (st.status === 'writing') setLabel(' ' + t('ai_status_writing') + ''); + else if (st.status === 'exporting') setLabel(' ' + t('ai_status_exporting') + ''); if (st.status === 'done') break; if (st.status === 'failed') { console.warn('AI annotation failed:', st.detail); diff --git a/processor/web/static/js/app.js b/processor/web/static/js/app.js index ab7c006ea1bddfe86e568cf57d720a0f4d20f383..e83d1e9c616a67629c25c76ffa6034e80945d686 100644 --- a/processor/web/static/js/app.js +++ b/processor/web/static/js/app.js @@ -1,1571 +1,1532 @@ -/* Episode list, info panel, review/delete/export */ - -function escHtml(s) { - if (!s) return ''; - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - -let selectedEpisodes = new Set(); // batch selection -let allEpisodes = []; -let hierarchyData = []; // Project → Task → Episode tree for the list view -const _expandedProjects = new Set(); // expanded project ids (default: collapsed) -const _collapsedTasks = new Set(); // collapsed task ids -let _loadEpoch = 0; // stale-response guard — discard out-of-order poll results -let _formatEpoch = 0; // 导出格式徽标防竞态:快速切换批次时丢弃过期响应 -let _silentReloadTimer = null; // coalesce background list refreshes after actions -let _listLoadController = null; // abort obsolete hierarchy requests on fast navigation -let _episodesLoadedAt = 0; // snapshot freshness for background reconciliation -let selectMode = false; // selection mode toggle — hides checkboxes by default - -// 视图缓存。筛选结果只依赖 [筛选条件 + 排序模式 + 层级快照],与勾选状态无关; -// 但勾选一行原先要重算三遍(visibleEpisodes → hierarchy → updateBatchUI), -// 每次都 spread 复制全部 episode。缓存后勾选只改 Set 和那一行 DOM。 -// _hierarchyRev 在层级数据变化时自增 —— 快照替换、局部增删、局部改字段都要加。 -let _hierarchyRev = 0; -let _viewCache = { key: null, groups: [], visible: [], visibleIds: new Set(), reviewedIds: [] }; -// 上一次渲染所依据的服务端快照指纹;一致就跳过一次整表重绘。 -let _hierarchySignatureAt = null; - -// A short-lived cross-navigation snapshot makes the Review page paint -// immediately after leaving Projects/Workflow. The server remains -// authoritative: loadEpisodes still refreshes in the background. -const REVIEW_SNAPSHOT_CACHE_KEY = 'egodata.review.hierarchy.v1'; -const REVIEW_SNAPSHOT_CACHE_TTL = 60 * 1000; - -function readReviewSnapshotCache() { - try { - const cached = JSON.parse(sessionStorage.getItem(REVIEW_SNAPSHOT_CACHE_KEY) || 'null'); - if (!cached || !Array.isArray(cached.projects) - || Date.now() - Number(cached.savedAt || 0) > REVIEW_SNAPSHOT_CACHE_TTL) return null; - return cached; - } catch (_) { return null; } -} - -function saveReviewSnapshotCache(projects) { - try { - sessionStorage.setItem(REVIEW_SNAPSHOT_CACHE_KEY, JSON.stringify({ - savedAt: Date.now(), projects, - })); - } catch (_) { /* storage quota/private mode — network refresh still works */ } -} - -// 渲染只依赖这些字段;其余字段(如 duration、camera_streams)变了不影响卡片。 -function _episodeRenderSignature(ep) { - return [ep.id, ep.status, ep.ai_quality_status, ep._uiWorkflowState, - ep.frame_count, ep.fps, ep.name, ep.episode_index, - (ep.camera_names || []).length, ep.timestamp, ep.created_at].join(':'); -} - -function _hierarchySignature(projects) { - let parts = []; - (projects || []).forEach(node => { - parts.push(`P${node.project?.id}:${node.project?.name}`); - (node.episodes || []).forEach(ep => parts.push(_episodeRenderSignature(ep))); - }); - return parts.join(';'); -} - -function applyHierarchySnapshot(projects, savedAt = Date.now()) { - const incoming = projects || []; - // 15 秒轮询每次都会走到这里。卡片渲染只依赖上面那些字段,内容没变时 - // 直接返回 —— 否则用户正勾选着,列表被整表重建,会闪、丢焦点、丢点击。 - const signature = _hierarchySignature(incoming); - if (signature === _hierarchySignatureAt) { - _episodesLoadedAt = savedAt; - return; - } - _hierarchySignatureAt = signature; - hierarchyData = incoming; - _hierarchyRev++; - _episodesLoadedAt = savedAt; - allEpisodes = hierarchyData.flatMap(node => - (node.episodes || []).map(e => ({ ...e, task_description: node.project.name }))); - // updateTaskFilter 可能清掉已失效的 filter-task —— 必须在算视图之前调用, - // 否则 currentView() 会按一个已经不存在的筛选条件构建缓存。 - updateTaskFilter(visibleEpisodesForCurrentView()); - updateEpisodeListCount(allEpisodes, '', document.getElementById('filter-task')?.value || ''); - renderEpisodeCards(); -} - -// ── Nav tree → filter bridge (called from base.html) ── - -function applyNavFilter(statusParam, taskName) { - // Set hidden filter controls - const statusSel = document.getElementById('filter-status'); - const taskSel = document.getElementById('filter-task'); - const searchInput = document.getElementById('search-input'); - if (statusSel) statusSel.value = statusParam; - const requestedTask = taskName || ''; - if (taskSel) taskSel.value = requestedTask; - if (searchInput) searchInput.value = requestedTask; - // Update URL - const url = new URL(document.location); - url.searchParams.set('status', statusParam); - url.searchParams.set('search', requestedTask); - history.replaceState({}, '', url); - // Close detail panel (if open), then filter the in-memory snapshot. - backToList(); - if (hierarchyData.length) { - renderEpisodeListFromMemory(); - scheduleSilentEpisodeRefresh(250); - } else { - loadEpisodes(); - } - // Highlight nav tree - if (typeof window.highlightNavActive === 'function') { - window.highlightNavActive(statusParam, taskName); - } -} -window.applyNavFilter = applyNavFilter; - -function syncReviewShortcutActive(status) { - const key = status === 'reviewed' ? 'approved' - : (status === 'failed' ? 'failed' : 'reviewing'); - document.querySelectorAll('.sidebar-sub[data-review-status]').forEach(link => { - link.classList.toggle('active', link.dataset.reviewStatus === key); - }); -} - -function switchReviewStatus(status, options = {}) { - const allowed = new Set(['completed', 'to_review', 'reviewed', 'failed']); - const nextStatus = allowed.has(status) ? status : 'completed'; - const statusSel = document.getElementById('filter-status'); - const taskSel = document.getElementById('filter-task'); - const searchInput = document.getElementById('search-input'); - if (statusSel) statusSel.value = nextStatus; - if (taskSel) taskSel.value = ''; - if (searchInput) searchInput.value = ''; - - const url = new URL(window.location.href); - url.searchParams.set('status', nextStatus); - url.searchParams.delete('search'); - if (options.replace) history.replaceState({}, '', url); - else history.pushState({}, '', url); - - syncReviewShortcutActive(nextStatus); - backToList(); - if (hierarchyData.length) { - renderEpisodeListFromMemory(); - // Keep the switch instant; only reconcile in the background when the - // current snapshot is older than a moment. - if (Date.now() - _episodesLoadedAt > 1000) scheduleSilentEpisodeRefresh(200); - } else { - loadEpisodes(); - } -} -window.switchReviewStatus = switchReviewStatus; - -function installReviewStatusNavigation() { - if (window.location.pathname !== '/review') return; - const statusByShortcut = { reviewing: 'completed', approved: 'reviewed', failed: 'failed' }; - document.querySelectorAll('.sidebar-sub[data-review-status]').forEach(link => { - link.addEventListener('click', event => { - event.preventDefault(); - switchReviewStatus(statusByShortcut[link.dataset.reviewStatus] || 'completed'); - }); - }); - window.addEventListener('popstate', () => { - const params = new URLSearchParams(window.location.search); - const status = params.get('status') || 'completed'; - const statusSel = document.getElementById('filter-status'); - const searchInput = document.getElementById('search-input'); - if (statusSel) statusSel.value = status; - if (searchInput) searchInput.value = params.get('search') || ''; - syncReviewShortcutActive(status); - if (hierarchyData.length) renderEpisodeListFromMemory(); - else loadEpisodes(); - }); -} - -// ── Init from URL param ───────────────────────────── - -function initStatusFromURL() { - const params = new URLSearchParams(document.location.search); - const statusParam = params.get('status') || 'completed'; - const sel = document.getElementById('filter-status'); - if (sel) sel.value = statusParam; - - const searchParam = params.get('search') || ''; - const searchInput = document.getElementById('search-input'); - if (searchInput && searchParam) searchInput.value = searchParam; -} - -// ── Sort order ────────────────────────────────────── - -const SORT_MODES = [ - { dir: 'asc', label: 'Name 1→N', icon: 'ant-design:ordered-list-outlined' }, - { dir: 'desc', label: 'Name N→1', icon: 'ant-design:ordered-list-outlined' }, -]; -let sortModeIdx = 0; // cycles 0→1→2→3→0 - -function currentSortMode() { - return SORT_MODES[sortModeIdx]; -} - -function toggleSortOrder() { - sortModeIdx = (sortModeIdx + 1) % SORT_MODES.length; - const mode = currentSortMode(); - const icon = document.getElementById('sort-order-icon'); - const label = document.getElementById('sort-order-label'); - const btn = document.getElementById('btn-sort-order'); - if (icon) icon.setAttribute('icon', mode.icon); - if (label) label.textContent = mode.label; - if (btn) btn.title = 'Sort: ' + mode.label; - // Re-sort in memory (no network round trip) — only refetch if not yet loaded - if (hierarchyData.length === 0) { - loadEpisodes(); - } else { - renderEpisodeListFromMemory(); - } -} - -// Extract trailing numeric suffix: "Chew_gum_0005" → 5, "ep_00020" → 20, "no_number" → 0 -function extractEpisodeNumber(name) { - const m = String(name || '').match(/(\d+)\s*$/); - return m ? parseInt(m[1], 10) : 0; -} - -// ── Selection mode ────────────────────────────────── - -function toggleSelectMode() { - selectMode = !selectMode; - if (!selectMode) { - // 退出选择模式:清空选择,但**不动项目的展开/折叠状态** —— - // 用户展开过哪些项目是他自己的布局,不该被模式切换重置。 - selectedEpisodes.clear(); - } - // 进入选择模式**不再自动展开所有项目**。项目行本身就有三态全选框, - // 折叠状态下点一下即可选中整批(卡片仍在 DOM 里),所以没有展开的必要; - // 自动展开会让几十个项目同时铺开,列表瞬间变长、滚动位置也丢了。 - // 退出时的按钮态与批量栏显隐统一交给 updateBatchUI。 - renderEpisodeListFromMemory(); -} - -// ── Load ──────────────────────────────────────────── - -function currentReviewStatus() { - return document.getElementById('filter-status')?.value || 'completed'; -} - -function isEpisodeTemporarilyHidden(ep) { - // Reprocessing invalidates the previous review artifact immediately. Keep - // the episode out of every list until the server publishes the new state; - // otherwise the old card can remain visible as "Processing…". - return Boolean(ep && (ep.status === 'processing' - || ep._uiWorkflowState === 'queued')); -} - -function matchesReviewStatus(status, filter = currentReviewStatus()) { - if (!filter) return true; - if (filter === 'completed' || filter === 'to_review') { - if (status === 'completed' || status === 'to_review') return true; - // processing 由 hierarchyForCurrentView 统一隐藏,避免用户打开 - // 旧产物;处理完成后由下一次静默刷新重新出现。 - return false; - } - if (filter === 'reviewed') { - return status === 'reviewed' || status === 'approved'; - } - return status === filter; -} - -function matchesEpisodeSearch(ep, projectName, rawSearch) { - const search = String(rawSearch || '').trim().toLowerCase(); - if (!search) return true; - return (ep.name || '').toLowerCase().includes(search) - || String(ep.id || '').toLowerCase().includes(search) - || String(projectName || '').toLowerCase().includes(search); -} - -// The server snapshot contains all live episode metadata. Status/search -// filtering happens here, so Reviewing ↔ Approved never starts another scan. -function hierarchyForCurrentView() { - const status = currentReviewStatus(); - const search = document.getElementById('search-input')?.value || ''; - return hierarchyData.map(node => ({ - ...node, - episodes: (node.episodes || []).filter(ep => - !isEpisodeTemporarilyHidden(ep) - && matchesReviewStatus(ep.status, status) - && matchesEpisodeSearch(ep, node.project?.name, search)), - })); -} - -// 缓存后的视图模型:分组(已按 task 筛选并按当前排序模式排好)+ 扁平的 -// 可见 / 已审核 id 列表。渲染、勾选、计数全部读这一份,不再各自重算。 -function currentView() { - const status = currentReviewStatus(); - const search = document.getElementById('search-input')?.value || ''; - const taskFilter = document.getElementById('filter-task')?.value || ''; - const dir = currentSortMode().dir === 'asc' ? 1 : -1; - const key = [_hierarchyRev, status, search, taskFilter, dir].join('|'); - if (_viewCache.key === key) return _viewCache; - - const groups = hierarchyForCurrentView() - .filter(node => !taskFilter || (node.project?.name || '') === taskFilter) - .map(node => ({ - project: node.project, - // 组内按尾部序号排序(Test1_000012 → 12),只排副本不动原始数据 - episodes: [...(node.episodes || [])].sort((a, b) => - (extractEpisodeNumber(a.name) - extractEpisodeNumber(b.name)) * dir), - })); - - const visible = []; - const visibleIds = new Set(); - const reviewedIds = []; - const framesById = new Map(); - groups.forEach(group => { - group.episodes.forEach(ep => { - visible.push({ ...ep, task_description: group.project?.name }); - visibleIds.add(String(ep.id)); - framesById.set(String(ep.id), Number(ep.frame_count) || 0); - if (ep.status === 'reviewed' || ep.status === 'approved') { - reviewedIds.push(String(ep.id)); - } - }); - }); - _viewCache = { key, groups, visible, visibleIds, reviewedIds, framesById }; - return _viewCache; -} - -function visibleEpisodesForCurrentView() { - return currentView().visible; -} - -async function loadEpisodes(options = {}) { - const silent = Boolean(options.silent); - if (!silent && _silentReloadTimer) { - clearTimeout(_silentReloadTimer); - _silentReloadTimer = null; - } - const taskFilter = document.getElementById('filter-task')?.value || ''; - const listEl = document.getElementById('episode-list-inline'); - if (!listEl) return; - - // Paint the last successful hierarchy immediately on a new page load. - // A normal request below still reconciles status/uploads in the background. - if (!silent && hierarchyData.length === 0) { - const cached = readReviewSnapshotCache(); - if (cached) applyHierarchySnapshot(cached.projects, Number(cached.savedAt) || Date.now()); - } - - // Save scroll position before innerHTML rebuild (prevent 15s poll reset) - const scrollContainer = document.getElementById('episode-list-section'); - const savedScrollTop = scrollContainer ? scrollContainer.scrollTop : 0; - - if (!silent && hierarchyData.length === 0) { - listEl.innerHTML = `
${t('loading')}
`; - } - - if (_listLoadController) _listLoadController.abort(); - const controller = new AbortController(); - _listLoadController = controller; - const fetchEpoch = ++_loadEpoch; - try { - // Hierarchy view: Project → Task (upload batch) → Episodes. - // Status and search are applied after the full snapshot arrives. - - // ``fetchEpoch`` was captured before dispatch so stale responses are ignored. - const res = await fetch('/api/v1/projects/hierarchy', { signal: controller.signal }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - if (fetchEpoch !== _loadEpoch) return; // newer request in flight — discard - saveReviewSnapshotCache(data.projects || []); - applyHierarchySnapshot(data.projects || []); - // Restore scroll position after render - if (scrollContainer) { - requestAnimationFrame(() => { scrollContainer.scrollTop = savedScrollTop; }); - } - } catch (err) { - if (err?.name === 'AbortError') return; - if (!silent && hierarchyData.length === 0) { - listEl.innerHTML = `
${t('load_failed')}: ${err.message}
`; - } - } finally { - if (_listLoadController === controller) _listLoadController = null; - } -} - - -function updateEpisodeListCount(episodes, search, taskFilter) { - const countEl = document.getElementById('episode-list-count'); - if (!countEl) return; - let filtered = episodes.filter(ep => !isEpisodeTemporarilyHidden(ep) - && matchesReviewStatus(ep.status)); - if (taskFilter) filtered = filtered.filter(ep => (ep.task_description || '') === taskFilter); - if (search) { - const s = search.toLowerCase(); - filtered = filtered.filter(ep => - (ep.name || '').toLowerCase().includes(s) || - (ep.task_description || '').toLowerCase().includes(s) || - String(ep.id).toLowerCase().includes(s)); - } - countEl.textContent = filtered.length + ' episodes'; -} - -function updateTaskFilter(episodes) { - // filter-task is now a hidden input (not a select). Values are set by applyNavFilter(). - // This function remains as a hook: clear the value if the task no longer exists in results. - const sel = document.getElementById('filter-task'); - if (!sel) return; - const current = sel.value; - if (current) { - const taskNames = new Set(episodes.map(e => e.task_description || 'unknown')); - if (!taskNames.has(current)) { - sel.value = ''; - } - } -} - - -function renderEpisodeCards() { - const listEl = document.getElementById('episode-list-inline'); - if (!listEl) return; - - // Auto-exit selection mode when switching away from Reviewed - const statusFilter = document.getElementById('filter-status')?.value || ''; - if (selectMode && statusFilter !== 'reviewed') { - selectMode = false; - selectedEpisodes.clear(); - } - - // 分组、筛选、排序都来自缓存的视图模型 —— 渲染只负责拼 HTML。 - const view = currentView(); - - // 项目文件夹**始终渲染**(含空项目/刚创建的项目):几个项目就显示几个 - // 文件夹,哪怕里面没有批次;只有没有任何项目时才显示 No data。 - if (view.groups.length === 0) { - listEl.innerHTML = `
${t('no_data')}
`; - updateBatchUI(); - return; - } - - // 任务概念已移除:项目 → Episodes 两层(组内排序已在 currentView 完成) - let html = ''; - view.groups.forEach((group) => { - const sorted = group.episodes; - const projectId = String(group.project?.id ?? ''); - // 默认折叠:只在用户点击展开过的项目才展开视频卡片 - const pCollapsed = !_expandedProjects.has(group.project?.id); - html += ` -
-
- ${projectSelectBoxHtml(projectId, sorted)} - - ${escHtml(group.project?.name || '')} - ${sorted.length} - - -
-
`; - if (sorted.length === 0) { - html += `
${t('no_tasks_in_project')}
`; - } - sorted.forEach(ep => { html += episodeCardHtml(ep, group.project?.name); }); - html += `
`; - }); - listEl.innerHTML = html; - syncProjectSelectDom(); - updateBatchUI(); -} - -// 项目行左侧的三态全选框。只作用于当前筛选下该项目可见的已审核批次; -// 折叠状态照常可点 —— 卡片仍在 DOM 里,只是 .project-body 被 hidden, -// 所以点一下就能选中折叠中的整批,不必先展开。 -function projectSelectBoxHtml(projectId, episodes) { - const statusFilter = document.getElementById('filter-status')?.value || ''; - if (!selectMode || statusFilter !== 'reviewed') return ''; - const reviewed = episodes.filter(ep => ep.status === 'reviewed' || ep.status === 'approved'); - const disabled = reviewed.length === 0 ? ' disabled' : ''; - return ``; -} - -// 该项目在当前视图下可见的、可勾选的批次 id。 -function projectReviewedIds(projectId) { - const group = currentView().groups.find( - item => String(item.project?.id ?? '') === String(projectId)); - if (!group) return []; - return group.episodes - .filter(ep => ep.status === 'reviewed' || ep.status === 'approved') - .map(ep => String(ep.id)); -} - -function toggleProjectSelect(projectId, checked) { - projectReviewedIds(projectId).forEach(id => { - if (checked) selectedEpisodes.add(id); - else selectedEpisodes.delete(id); - }); - syncProjectSelectionDom(projectId); - updateBatchUI(); -} - -// 把选中状态同步回 DOM:项目行三态框 + 该项目下每张卡片。只碰受影响的项目, -// 不重建整个列表 —— 这是勾选不再"闪一下"的关键。 -function syncProjectSelectionDom(onlyProjectId) { - document.querySelectorAll('#episode-list-inline .project-group').forEach(group => { - if (onlyProjectId !== undefined - && String(group.dataset.projectId) !== String(onlyProjectId)) return; - group.querySelectorAll('input.batch-checkbox').forEach(box => { - box.checked = selectedEpisodes.has(String(box.dataset.episodeId)); - box.closest('.episode-card')?.classList.toggle('selected', box.checked); - }); - syncProjectSelectBox(group); - }); -} - -function syncProjectSelectBox(group) { - const box = group.querySelector('.project-checkbox'); - if (!box) return; - const ids = projectReviewedIds(box.dataset.projectId); - const selected = ids.filter(id => selectedEpisodes.has(id)).length; - box.checked = ids.length > 0 && selected === ids.length; - box.indeterminate = selected > 0 && selected < ids.length; - const badge = group.querySelector('.project-select-count'); - if (badge) badge.textContent = selected > 0 ? `${selected}/${ids.length}` : ''; -} - -function syncProjectSelectDom() { - syncProjectSelectionDom(); -} - -function toggleProjectCollapse(projectId) { - if (_expandedProjects.has(projectId)) _expandedProjects.delete(projectId); - else _expandedProjects.add(projectId); - // 只切这一个项目的 body 与箭头:整表重绘会丢掉滚动位置和焦点, - // 大列表上肉眼可见地闪。 - document.querySelectorAll('#episode-list-inline .project-group').forEach(group => { - if (String(group.dataset.projectId) !== String(projectId)) return; - const collapsed = !_expandedProjects.has(projectId); - group.querySelector('.project-body')?.classList.toggle('hidden', collapsed); - group.querySelector('.project-caret') - ?.setAttribute('icon', collapsed ? 'ant-design:right-outlined' : 'ant-design:down-outlined'); - }); -} - -// Apply small action results locally so the list responds immediately. The -// server remains authoritative; a coalesced silent refresh reconciles state -// after the filesystem-backed hierarchy has caught up. -function renderEpisodeListFromMemory() { - // updateTaskFilter 可能清掉已失效的 filter-task,所以要在取视图之前调用。 - updateTaskFilter(visibleEpisodesForCurrentView()); - updateEpisodeListCount(allEpisodes, '', document.getElementById('filter-task')?.value || ''); - renderEpisodeCards(); -} - -function removeEpisodeFromLocalList(episodeId) { - const id = String(episodeId); - selectedEpisodes.delete(id); - _hierarchyRev++; - hierarchyData.forEach(node => { - node.episodes = (node.episodes || []).filter(ep => String(ep.id) !== id); - }); - allEpisodes = allEpisodes.filter(ep => String(ep.id) !== id); - renderEpisodeListFromMemory(); -} - -function updateEpisodeInLocalList(episodeId, patch) { - const id = String(episodeId); - _hierarchyRev++; - hierarchyData.forEach(node => { - const ep = (node.episodes || []).find(item => String(item.id) === id); - if (ep) Object.assign(ep, patch); - }); - const flat = allEpisodes.find(ep => String(ep.id) === id); - if (flat) Object.assign(flat, patch); - renderEpisodeListFromMemory(); -} - -function scheduleSilentEpisodeRefresh(delay = 1200) { - clearTimeout(_silentReloadTimer); - _silentReloadTimer = setTimeout(() => { - _silentReloadTimer = null; - loadEpisodes({ silent: true }); - }, delay); -} - -function toggleTaskCollapse(taskId) { - if (_collapsedTasks.has(taskId)) _collapsedTasks.delete(taskId); - else _collapsedTasks.add(taskId); - renderEpisodeListFromMemory(); -} - -function episodeCardHtml(ep, taskName) { - const activeClass = currentEpisodeId === ep.id ? 'active' : ''; - // 前端入队标记(点击后立即显示)或后端真实状态(静默刷新后保持显示) - const isWorkflowQueued = ep._uiWorkflowState === 'queued' || ep.status === 'processing'; - // AI 标注运行中:整批数据等标注完成后才允许查看 - const isAiAnnotating = !isWorkflowQueued && ep.ai_quality_status === 'running'; - const cardClass = (isWorkflowQueued || isAiAnnotating) - ? 'cursor-default opacity-80' - : 'cursor-pointer'; - const cardClick = (isWorkflowQueued || isAiAnnotating) - ? '' : `onclick="selectEpisode('${ep.id}')"`; - const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; - const isFailed = ep.status === 'failed'; - const statusDot = (isWorkflowQueued || isAiAnnotating) - ? '' - : (isFailed - ? '' - : (isReviewed - ? '' - : '')); - const statusText = isWorkflowQueued ? t('processing') - : (isAiAnnotating ? t('ai_annotating') - : (isFailed ? t('stat_failed') : (isReviewed ? t('reviewed') : t('reviewing')))); - const statusColor = (isWorkflowQueued || isAiAnnotating) ? 'text-blue-400' - : (isFailed ? 'text-red-400' : (isReviewed ? 'text-green-400' : 'text-yellow-400')); - const cameraCount = (ep.camera_names || []).length; - const timestamp = ep.timestamp || ''; - const time = timestamp ? timestamp : new Date(ep.created_at).toLocaleDateString('zh-CN'); - - const checked = selectedEpisodes.has(ep.id) ? 'checked' : ''; - const checkbox = (isReviewed && selectMode) - ? `` - : ''; - - const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation'; - const statusFilter = document.getElementById('filter-status')?.value - || new URLSearchParams(document.location.search).get('status') - || 'completed'; - const isReviewingMode = statusFilter === 'completed' || statusFilter === 'to_review'; - const userRole = String(window.__EGO_USER__?.role || '').toLowerCase(); - const canReprocess = userRole === 'admin' || userRole === 'engineer'; - // processing(含前端入队标记)显示禁用的"处理中"状态按钮,不要求角色; - // 主动重跑按钮仍只对 admin/engineer 开放且要求 completed/to_review。 - const workflowButton = !isAnnotationPage && isReviewingMode - ? (isWorkflowQueued - ? `` - : (canReprocess && (ep.status === 'completed' || ep.status === 'to_review') - ? `` - : '')) - : ''; - const buttons = isAnnotationPage ? '' : (isFailed - ? `
- - -
` - : (isReviewed - ? `
- - -
` - : `
- - -
${workflowButton}`)); - - return ` -
- ${checkbox} -
-
- ${statusDot} - ${statusText} -
-
- ${typeof ep.episode_index === 'number' - ? `
#${ep.episode_index}
` - : `
${ep.name || taskName || ep.id.slice(0, 8)}
`} -
-
-
${ep.fps || 30}FPS · ${ep.frame_count || 0}${t('frame')}
- ${cameraCount > 0 ? `
${cameraCount} ${t('cameras')}
` : ''} -
${time}
-
- ${buttons} -
-
`; -} - - -// ── Back to list ───────────────────────────────────── - -function backToList() { - // Closing detail must release decoders, 3D canvases, timers, sockets and - // in-flight media requests. Hiding the panel alone leaves the old video - // workload alive behind Reviewing/Approved and makes the next click lag. - if (typeof clearEpisodeWorkspace === 'function') clearEpisodeWorkspace(); - const detail = document.getElementById('episode-detail'); - const listSection = document.getElementById('episode-list-section'); - if (detail) detail.classList.add('hidden'); - if (listSection) listSection.classList.remove('hidden'); - // 返回批次列表 → 隐藏漂浮预览控件(点开视频后才有) - if (typeof setPreviewPanelsVisible === 'function') setPreviewPanelsVisible(false); - const warning = document.getElementById('input-warning-banner'); - if (warning) { - warning.classList.add('hidden'); - warning.innerHTML = ''; - } -} - -function renderInputWarning(ep) { - const warning = document.getElementById('input-warning-banner'); - if (!warning) return; - const missing = []; - const matched = []; - (ep.exceptions || []).filter(ex => ex.kind === 'input_missing').forEach(ex => { - (ex.missing || []).forEach(value => { - if (!missing.includes(String(value))) missing.push(String(value)); - }); - (ex.matched || []).forEach(value => { - if (!matched.includes(String(value))) matched.push(String(value)); - }); - }); - if (!missing.length) { - warning.classList.add('hidden'); - warning.innerHTML = ''; - return; - } - warning.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(t('input_missing_title')) + '
' + - '
' + escHtml(t('input_missing_detail')) + '
' + - '
' + escHtml(missing.join(', ')) + '
' + - (matched.length ? '
✓ ' + escHtml(matched.join(', ')) + '
' : '') + - '
' + - '
'; - warning.classList.remove('hidden'); -} - -// ── Batch selection ────────────────────────────────── - -function toggleBatchSelect(episodeId, checked) { - const id = String(episodeId); - if (checked) selectedEpisodes.add(id); - else selectedEpisodes.delete(id); - // 只同步这一行与它所属项目的三态框 —— 不重算筛选、不重建列表。 - const card = document.querySelector( - `#episode-list-inline .episode-card[data-episode-id="${CSS.escape(id)}"]`); - card?.classList.toggle('selected', checked); - const group = card?.closest('.project-group'); - if (group) syncProjectSelectBox(group); - updateBatchUI(); -} - - -function toggleSelectAll(checked) { - if (!selectMode) return; - if ((document.getElementById('filter-status')?.value || '') !== 'reviewed') return; - - currentView().reviewedIds.forEach(id => { - if (checked) selectedEpisodes.add(id); - else selectedEpisodes.delete(id); - }); - // 直接写已渲染的 checkbox,不重建整个列表。 - document.querySelectorAll('#episode-list-inline input.batch-checkbox').forEach(box => { - box.checked = selectedEpisodes.has(String(box.dataset.episodeId)); - box.closest('.episode-card')?.classList.toggle('selected', box.checked); - }); - syncProjectSelectDom(); - updateBatchUI(); -} - - -function updateBatchUI() { - const selectAllBar = document.getElementById('select-all-bar'); - const batchBar = document.getElementById('batch-bar'); - const selectBtn = document.getElementById('btn-toggle-select'); - const statusFilter = document.getElementById('filter-status')?.value || ''; - const isReviewedMode = statusFilter === 'reviewed'; - const showSelection = selectMode && isReviewedMode; - - // 「Select」只在非选择模式出现 —— 进入选择模式后由批量栏里的 Cancel 接管。 - if (selectBtn) { - selectBtn.classList.toggle('hidden', !isReviewedMode || selectMode); - } - // Force-exit selection mode when switching away from Reviewed - if (!isReviewedMode && selectMode) { - selectMode = false; - selectedEpisodes.clear(); - } - - if (selectAllBar) { - selectAllBar.classList.toggle('hidden', !showSelection); - } - // 批量栏只要在选择模式就显示(哪怕一集都没选)—— 否则 Cancel 没地方放。 - if (batchBar) { - batchBar.classList.toggle('hidden', !showSelection); - } - if (!showSelection) { - if (!selectMode) selectedEpisodes.clear(); - return; - } - - // Update counts - const view = currentView(); - const reviewedCount = view.reviewedIds.length; - const selCount = view.reviewedIds.filter(id => selectedEpisodes.has(id)).length; - - const selectCount = document.getElementById('select-count'); - if (selectCount) selectCount.textContent = `(${selCount}/${reviewedCount})`; - - const selectAllCb = document.getElementById('select-all-checkbox'); - if (selectAllCb) { - selectAllCb.checked = reviewedCount > 0 && selCount === reviewedCount; - selectAllCb.indeterminate = selCount > 0 && selCount < reviewedCount; - } - - // 选中规模:让用户在点之前知道这一下要跑多久 —— 打包下载是秒级原样 zip, - // 导出数据集是整库重建(实测约 30 秒/集)。 - let frames = 0; - view.reviewedIds.forEach(id => { - if (selectedEpisodes.has(id)) frames += view.framesById.get(id) || 0; - }); - const batchCount = document.getElementById('batch-count'); - if (batchCount) { - batchCount.textContent = t('batch_selected_summary') - .replace('%s', String(selCount)) - .replace('%f', frames.toLocaleString()); - } - const exportBtnLabel = document.getElementById('batch-export-label'); - if (exportBtnLabel) { - const minutes = Math.max(1, Math.round(selCount * 30 / 60)); - exportBtnLabel.textContent = selCount >= 5 - ? `${t('batch_export_dataset')} ~${minutes}${t('minutes_short')}` - : t('batch_export_dataset'); - } - // 一集都没选时两个动作按钮禁用(Cancel 仍可点,否则退不出去)。 - const exportBtn = document.getElementById('batch-export-btn'); - if (exportBtn) exportBtn.disabled = selCount === 0; - const zipBtn = document.getElementById('batch-zip-btn'); - if (zipBtn) zipBtn.disabled = selCount === 0; -} - - -// 导出数据集:合并成一个可训练数据集。整库重建 —— 每路视频重编码、 -// 逐帧重采样、全量重算 stats,实测约 30 秒/集。 -async function batchDownload(button = null) { - if (selectedEpisodes.size === 0) return; - const ids = Array.from(selectedEpisodes); - const done = await startReviewExport(ids, null, null, button); - if (done && selectMode) toggleSelectMode(); -} - -// 打包下载:每集一个目录原样 zip,不重建,秒级返回。 -// 用原生表单提交而非 fetch + blob —— 浏览器自己流式写盘,不会把整个 -// zip 缓冲进内存(几十集的包很容易上 GB)。 -function batchZipDownload(button = null) { - if (selectedEpisodes.size === 0) return; - const form = document.createElement('form'); - form.method = 'POST'; - form.action = '/api/v1/export/batch-download'; - form.style.display = 'none'; - const field = document.createElement('input'); - field.type = 'hidden'; - field.name = 'episode_ids'; - field.value = Array.from(selectedEpisodes).join(','); - form.appendChild(field); - document.body.appendChild(form); - form.submit(); - form.remove(); - if (button) button.blur(); -} - - -function waitForExportJob(jobId, button) { - const originalHtml = button ? button.innerHTML : null; - return new Promise(resolve => { - const poll = async () => { - try { - const res = await fetch(`/api/v1/export/${jobId}`); - const job = await res.json(); - if (job.status === 'completed') { - resolve(true); - return; - } - if (job.status === 'failed') { - alert('Export failed: ' + (job.error || 'unknown')); - resolve(false); - return; - } - if (button) { - // 导出期间按钮直接显示百分比进度,不再静默等待 - const pct = Math.round((Number(job.progress) || 0) * 100); - button.textContent = 'Exporting… ' + pct + '%'; - } - } catch (err) { - alert('Export status failed: ' + err.message); - resolve(false); - return; - } - setTimeout(poll, 800); - }; - poll(); - }).finally(() => { - if (button) { - if (originalHtml !== null) button.innerHTML = originalHtml; - button.disabled = false; - button.dataset.exporting = ''; - button.classList.remove('opacity-60', 'cursor-wait'); - } - }); -} - - -async function startReviewExport(ids, format, datasetName, button = null) { - if (!ids || ids.length === 0) return false; - if (button) { - button.disabled = true; - button.dataset.exporting = '1'; - button.classList.add('opacity-60', 'cursor-wait'); - } - try { - const payload = { - episode_ids: ids, - split_ratio: 0.9, - }; - if (datasetName) payload.dataset_name = datasetName; - // Normal review-page exports omit the format deliberately: the - // backend resolves it from the selected project's workflow node. - if (format) payload.export_format = format; - const res = await fetch('/api/v1/export/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert('Export failed: ' + (err.detail || 'unknown')); - if (button) { - button.disabled = false; - button.dataset.exporting = ''; - button.classList.remove('opacity-60', 'cursor-wait'); - } - return false; - } - const job = await res.json(); - const done = await waitForExportJob(job.id, button); - if (done) { - window.location.href = `/api/v1/export/download/${job.id}`; - return true; - } - return false; - } catch (err) { - alert('Export failed: ' + err.message); - if (button) { - button.disabled = false; - button.dataset.exporting = ''; - button.classList.remove('opacity-60', 'cursor-wait'); - } - return false; - } -} - - -// ── Select / Info Panel ────────────────────────────── - -function showAiPendingGate(episodeId) { - // 全屏等待遮罩:AI 标注未完成时批次数据不可查看。每 4 秒轮询层级 - // 接口,标注结束(非 running)后自动移除遮罩并重新进入批次。 - const listSection = document.getElementById('episode-list-section'); - const detail = document.getElementById('episode-detail'); - if (listSection) listSection.classList.add('hidden'); - if (detail) detail.classList.remove('hidden'); - let overlay = document.getElementById('ai-pending-overlay'); - if (!overlay) { - overlay = document.createElement('div'); - overlay.id = 'ai-pending-overlay'; - overlay.className = 'fixed inset-0 z-[999] flex flex-col items-center justify-center gap-3 bg-black/85'; - document.body.appendChild(overlay); - } - overlay.innerHTML = ` - -
${t('ai_annotating')}
-
${t('ai_annotating_hint')}
`; - if (overlay.dataset.polling === '1') return; - overlay.dataset.polling = '1'; - (async function poll() { - for (let i = 0; i < 300; i++) { // 最多约 20 分钟 - await new Promise(resolve => setTimeout(resolve, 4000)); - try { - const res = await fetch('/api/v1/projects/hierarchy'); - if (!res.ok) continue; - const data = await res.json(); - const projects = Array.isArray(data) - ? data : (data && data.projects) || []; - let done = false; - for (const projectNode of projects) { - const hit = (projectNode.episodes || []) - .find(e => e.id === episodeId); - if (!hit) continue; - if (hit.ai_quality_status !== 'running') { - done = true; // passed / failed / null → 可查看 - } else if (hit.status === 'processing') { - done = true; // 回到 processing,由现有门禁接管 - } - break; - } - if (done) { - overlay.dataset.polling = ''; - overlay.remove(); - if (typeof selectEpisode === 'function') { - selectEpisode(episodeId); - } - return; - } - } catch (_) { /* 网络抖动,继续轮询 */ } - } - overlay.dataset.polling = ''; - overlay.remove(); - if (typeof backToList === 'function') backToList(); - })(); -} - -function selectEpisode(episodeId) { - const ep = allEpisodes.find(e => e.id === episodeId); - if (!ep) return; - // Reprocessing keeps the list card visible, but its previous artifacts are - // no longer a valid review target. Do not reopen stale media while the - // worker is replacing the processed output. - if (ep.status === 'processing' || ep._uiWorkflowState === 'queued') { - backToList(); - return; - } - // AI 标注运行中:整批数据等标注完成后才显示(全屏等待遮罩 + 轮询, - // 完成后自动载入)。 - if (ep.ai_quality_status === 'running') { - showAiPendingGate(episodeId); - return; - } - - // Annotation is a separate workspace: start every selected file with a - // clean editor while leaving the review page's approval flow untouched. - if (window.EGODATA_PAGE_MODE === 'annotation' && typeof hideAnnotationForm === 'function') { - hideAnnotationForm(); - } - - // Highlight card - document.querySelectorAll('.episode-card').forEach(el => el.classList.remove('active')); - const card = document.querySelector('.episode-card[data-episode-id="' + episodeId + '"]'); - if (card) card.classList.add('active'); - - // Load video - const cameras = ep.camera_names || []; - const playbackMeta = { frameCount: ep.frame_count, fps: ep.fps }; - if (typeof loadGroupedEpisodeVideo === 'function') { - loadGroupedEpisodeVideo(episodeId, cameras, { - hasSkeleton: Boolean(ep.has_skeleton), - ...playbackMeta, - }); - } else { - loadEpisodeVideo(episodeId, cameras, playbackMeta); - } - - // Switch to detail view: hide list, show detail panel - const listSection = document.getElementById('episode-list-section'); - const detail = document.getElementById('episode-detail'); - if (listSection) listSection.classList.add('hidden'); - if (detail) detail.classList.remove('hidden'); - // 进入批次详情(审核/通过/标注页)→ 显示漂浮预览控件 - if (typeof setPreviewPanelsVisible === 'function') setPreviewPanelsVisible(true); - renderInputWarning(ep); - - // Fill detail header - const nameEl = document.getElementById('detail-episode-name'); - if (nameEl) { - const episodeNumber = typeof ep.episode_index === 'number' - ? `#${ep.episode_index}` : ''; - const title = ep.task_description || ep.name || ep.id.slice(0, 8); - nameEl.textContent = episodeNumber ? `${episodeNumber} · ${title}` : title; - nameEl.title = ep.id || title; - } - const statusBadge = document.getElementById('detail-status-badge'); - if (statusBadge) { - const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; - const isFailed = ep.status === 'failed'; - const isProcessing = ep.status === 'processing'; - statusBadge.className = 'text-xs flex-shrink-0 ' + (isFailed ? 'text-red-400' : (isReviewed ? 'text-green-400' : (isProcessing ? 'text-blue-400' : 'text-yellow-400'))); - statusBadge.textContent = isFailed ? t('stat_failed') : (isReviewed ? t('reviewed') : (isProcessing ? t('processing') : t('reviewing'))); - } - - // Fill detail info - const infoDiv = document.getElementById('episode-detail-info'); - const meta = ep.meta || {}; - const timestamp = meta.timestamp || ''; - const time = timestamp || new Date(ep.created_at).toLocaleString('zh-CN'); - const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; - const isFailed = ep.status === 'failed'; - const duration = ep.fps > 0 ? (ep.frame_count / ep.fps).toFixed(1) : '0'; - const cameraList = (ep.camera_names || []).join(', '); - - let cleaningHTML = ''; - if (ep.cleaning_report) { - const cr = ep.cleaning_report; - if (cr.passed) { - cleaningHTML = '
Cleaning passed
'; - } else { - const failList = (cr.checks || []).filter(c => !c.passed).map(c => c.name).join(', '); - cleaningHTML = '
Cleaning failed
'; - } - } - - // 上传不匹配/运行失败异常:点击文件后在此详情显示(不在项目列表显示) - let exceptionHTML = ''; - if (ep.exceptions && ep.exceptions.length) { - exceptionHTML = ep.exceptions.map(function (ex) { - const kindLabel = ex.kind === 'run_failed' - ? t('exception_kind_failed') - : (ex.kind === 'input_missing' ? t('exception_kind_missing') : t('exception_kind_mismatch')); - const safeMsg = escHtml(ex.message || ''); - const msg = safeMsg ? ': ' + safeMsg : ''; - const color = ex.kind === 'input_missing' ? 'text-amber-300' : 'text-red-400'; - return '
' + - ' ' + kindLabel + msg + '
'; - }).join(''); - } - - const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation'; - const buttons = isAnnotationPage ? '' : (isFailed - ? '' + - '' - : (ep.status === 'processing' - ? '' + - '' - : (isReviewed - ? '' + - '' - : '' + - ''))); - - if (infoDiv) { - infoDiv.innerHTML = - '
' + - (cleaningHTML || '') + - (exceptionHTML || '') + - '
' + - '
' + ep.frame_count + ' frames · ' + ep.fps + ' FPS · ' + duration + 's
' + - (cameraList ? '
' + cameraList + '
' : '') + - '
' + time + '
' + - '' + - '' + - '
' + - '
' + - 'Frame' + - '-' + - '/ ' + ep.frame_count + '' + - '
' + - '
' + buttons + '
' + - '
' + - '
'; - if (typeof updateEpisodeDetailSyncStatus === 'function') updateEpisodeDetailSyncStatus(); - - // 导出格式徽标(工作流连接驱动:HDF5 / LeRobot v2.1 / v3.0 / Raw 兜底) - const fmtEl = document.getElementById('episode-detail-format'); - if (fmtEl) { - fmtEl.innerHTML = ''; - if (!isAnnotationPage && isReviewed) { - const fmtEpoch = ++_formatEpoch; - fetch(`/api/v1/export/episode-format/${episodeId}`) - .then(r => r.json()) - .then(d => { - if (fmtEpoch !== _formatEpoch) return; // 已切换批次,丢弃过期响应 - const el = document.getElementById('episode-detail-format'); - if (!el) return; - const label = escHtml(d.label || 'Raw'); - const badge = d.available - ? '' + label + '' - : '' + label + ''; - el.innerHTML = '
' + - t('export') + ': ' + badge + - (d.available ? ' · ' : '') + - '
'; - }) - .catch(() => {}); - } - } - } - -// Re-export:只重建导出,不重跑检测(复用最新 run 的检测产物 + -// 当前工作流的导出配置/连线)。Review 详情 Export 徽标旁按钮。 -// (挂 window:inline onclick 需要全局可见) -window.reExportEpisode = async function () { - if (!currentEpisodeId) return; - const btn = document.getElementById('btn-re-export'); - if (!btn || btn.dataset.busy === '1') return; - btn.dataset.busy = '1'; - btn.textContent = t('re_exporting') + '…'; - let finalStatus = 'done'; - try { - const res = await fetch(`/api/v1/export/re-export/${currentEpisodeId}`, { method: 'POST' }); - if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.status); - for (let i = 0; i < 300; i++) { - await new Promise(r => setTimeout(r, 1000)); - let st = { status: 'running' }; - try { - st = await (await fetch(`/api/v1/export/re-export/${currentEpisodeId}/status`)).json(); - } catch (_) { /* keep polling */ } - if (st.status === 'done' || st.status === 'failed' || st.status === 'interrupted') { - finalStatus = st.status; - if (st.status !== 'done') { - alert(t('re_export_failed') + ': ' + (st.detail || st.status)); - } else { - alert(t('re_export_done') + ': ' + (st.detail || '')); - } - break; - } - } - } catch (err) { - finalStatus = 'failed'; - alert(t('re_export_failed') + ': ' + (err.message || err)); - } - btn.dataset.busy = ''; - btn.textContent = t('re_export'); - if (finalStatus === 'done') { - // 刷新徽标(格式可能已变) - const fmtEpoch = ++_formatEpoch; - try { - const d = await (await fetch(`/api/v1/export/episode-format/${currentEpisodeId}`)).json(); - if (fmtEpoch !== _formatEpoch || !currentEpisodeId) return; - const el = document.getElementById('episode-detail-format'); - if (!el) return; - const label = escHtml(d.label || 'Raw'); - const badge = d.available - ? '' + label + '' - : '' + label + ''; - el.innerHTML = '
' + - t('export') + ': ' + badge + - (d.available ? ' · ' : '') + - '
'; - } catch (_) { /* 刷新失败下次进入详情自动重取 */ } - } -} - - // Update frame info header - const frameHeader = document.getElementById('frame-info-header'); - const totalFramesEl = document.getElementById('detail-total-frames'); - if (frameHeader) frameHeader.classList.remove('hidden'); - if (totalFramesEl) totalFramesEl.textContent = ep.frame_count || 0; - - // Show frame controls - if (typeof showAnnotationUI === 'function') showAnnotationUI(); - - // Annotations are loaded by loadEpisodeVideo after frame data is ready - // (avoid race: loadAnnotations needs episodeTotalFrames from loadFrameData) -} - - -// ── Review ────────────────────────────────────────── - -async function markReviewed(episodeId) { - try { - const res = await fetch(`/api/v1/episode/${episodeId}/review`, { method: 'POST' }); - if (res.ok) { - backToList(); - removeEpisodeFromLocalList(episodeId); - scheduleSilentEpisodeRefresh(); - } - } catch (err) { - alert('Operation failed: ' + err.message); - } -} - - -async function unreviewEpisode(episodeId) { - try { - const res = await fetch(`/api/v1/episode/${episodeId}/unreview`, { method: 'POST' }); - if (res.ok) { - backToList(); - removeEpisodeFromLocalList(episodeId); - scheduleSilentEpisodeRefresh(); - } - } catch (err) { - alert('Operation failed: ' + err.message); - } -} - - -async function retryEpisode(episodeId) { - try { - const res = await fetch(`/api/v1/episode/${episodeId}/retry`, { method: 'POST' }); - if (res.ok) { - backToList(); - removeEpisodeFromLocalList(episodeId); - scheduleSilentEpisodeRefresh(); - } - } catch (err) { - alert('Operation failed: ' + err.message); - } -} - - -// ── Reprocess(主动重新处理)────────────────────────── - -async function reprocessEpisode(episodeId) { - if (!confirm(t('confirm_reprocess'))) return; - // The worker will replace the canonical parquet. Do not reuse the old - // full depth/3D browser buffers when this episode is opened again. - if (typeof window.invalidateEpisodePlaybackCache === 'function') { - window.invalidateEpisodePlaybackCache(episodeId); - } - // Clear the old detail workspace before waiting for the queue response. - // Reprocessing is asynchronous; the old media must not remain active - // while the user navigates to another review section. - backToList(); - // Mark the local snapshot before the POST resolves. This closes the small - // window in which a fast click could reopen the old completed artifact. - updateEpisodeInLocalList(episodeId, { _uiWorkflowState: 'queued' }); - try { - const res = await fetch(`/api/v1/episode/${episodeId}/reprocess`, { method: 'POST' }); - if (res.ok) { - scheduleSilentEpisodeRefresh(); - } else { - updateEpisodeInLocalList(episodeId, { _uiWorkflowState: null }); - const err = await res.json(); - alert(t('reprocess_failed') + (err.detail || res.status)); - } - } catch (err) { - updateEpisodeInLocalList(episodeId, { _uiWorkflowState: null }); - alert(t('reprocess_failed') + err.message); - } -} - - -// ── Delete ────────────────────────────────────────── - -async function deleteEpisode(episodeId) { - if (!confirm(t('confirm_trash'))) return; - - try { - const res = await fetch(`/api/v1/episode/${episodeId}/delete`, { method: 'POST' }); - if (res.ok) { - updateTrashBadge(); // 垃圾桶徽标立即 +1,无需刷新页面 - backToList(); // instant feedback — close detail, show list - if (currentEpisodeId === episodeId) { - document.getElementById('video-grid').innerHTML = - '
' + t('deleted_msg') + '
'; - // Clear annotations - annotations = []; - if (typeof clearAnnotationOverlay === 'function') clearAnnotationOverlay(); - } - removeEpisodeFromLocalList(episodeId); - scheduleSilentEpisodeRefresh(); - } - } catch (err) { - alert(t('op_failed') + ': ' + err.message); - } -} - - -async function restoreEpisode(episodeId) { - try { - const res = await fetch(`/api/v1/episode/${episodeId}/restore`, { method: 'POST' }); - if (res.ok) { - updateTrashBadge(); // 恢复后徽标立即 -1 - loadTrashList(); - if (typeof refreshReviewTree === 'function') refreshReviewTree(); - } - } catch (err) { - alert(t('restore_failed') + ': ' + err.message); - } -} - - -async function permanentDeleteEpisode(episodeId) { - if (!confirm(t('confirm_permanent'))) return; - try { - const res = await fetch(`/api/v1/episode/${episodeId}/permanent`, { method: 'DELETE' }); - if (res.ok) { - updateTrashBadge(); - loadTrashList(); - if (typeof refreshReviewTree === 'function') refreshReviewTree(); - } - } catch (err) { - alert(t('delete_failed') + ': ' + err.message); - } -} - - -async function purgeTrash() { - if (!confirm(t('confirm_purge'))) return; - try { - const res = await fetch('/api/v1/episodes/purge-trash', { method: 'POST' }); - if (res.ok) { - updateTrashBadge(); - loadTrashList(); - if (typeof refreshReviewTree === 'function') refreshReviewTree(); - } - } catch (err) { - alert(t('purge_failed') + ': ' + err.message); - } -} - - -// ── Export ──────────────────────────────────────────── - -async function downloadEpisode(episodeId) { - // A single episode already has its workflow-published export product. - // That endpoint returns the version selected in the workflow export node. - window.location.href = `/api/v1/export/download-episode/${episodeId}`; -} - - -// ── Export Page ─────────────────────────────────────── - -async function loadExportJobs() { - const listEl = document.getElementById('export-list'); - if (!listEl) return; - - try { - const res = await fetch('/api/v1/export/list?limit=50'); - const jobs = await res.json(); - - if (jobs.length === 0) { - listEl.innerHTML = 'No export jobs'; - return; - } - - listEl.innerHTML = jobs.map(job => { - const time = new Date(job.created_at).toLocaleString('zh-CN'); - const statusColors = { pending: 'text-yellow-400', running: 'text-blue-400', completed: 'text-green-400', failed: 'text-red-400' }; - const statusText = { pending: 'Pending', running: 'Running', completed: 'Completed', failed: 'Failed' }; - const progressBar = job.status === 'running' - ? `
` - : job.status === 'completed' ? '100%' : '-'; - const actions = job.status === 'completed' - ? `
Download` - : job.status === 'running' - ? `` - : ''; - - return ` - ${job.dataset_name} - ${statusText[job.status]||job.status} - ${progressBar} - ${time} - ${actions} - `; - }).join(''); - } catch (err) { - listEl.innerHTML = `Load failed`; - } -} - - -async function startExport() { - const name = document.getElementById('new-dataset-name')?.value; - const ratio = parseFloat(document.getElementById('new-split-ratio')?.value) || 0.9; - if (!name) return alert('Please enter a dataset name'); - - try { - const res = await fetch('/api/v1/export/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dataset_name: name, episode_ids: null, split_ratio: ratio }), - }); - if (res.ok) { document.getElementById('new-dataset-name').value = ''; loadExportJobs(); } - else { const err = await res.json(); alert('Export creation failed: ' + (err.detail || 'unknown')); } - } catch (err) { - alert('Export creation failed: ' + err.message); - } -} - - -// ── Init ──────────────────────────────────────────── - -document.addEventListener('DOMContentLoaded', () => { - // Apply data-i18n translations - document.querySelectorAll('[data-i18n]').forEach(el => { - el.textContent = t(el.dataset.i18n); - }); - document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { - el.placeholder = t(el.dataset.i18nPlaceholder); - }); - // Init nav labels - const navReview = document.getElementById('nav-review'); - const navTrash = document.getElementById('nav-trash'); - if (navReview) navReview.textContent = t('video_review'); - if (navTrash) navTrash.textContent = t('trash'); - - if (document.getElementById('episode-list-inline')) { - initStatusFromURL(); - installReviewStatusNavigation(); - loadEpisodes(); - setInterval(() => { loadEpisodes({ silent: true }); updateTrashBadge(); }, 15000); - updateTrashBadge(); - // 两个批量按钮的提示:说清一个是重建、一个是原样打包。 - const exportBtn = document.getElementById('batch-export-btn'); - if (exportBtn) exportBtn.title = t('batch_export_hint'); - const zipBtn = document.getElementById('batch-zip-btn'); - if (zipBtn) zipBtn.title = t('batch_zip_hint'); - } - if (document.getElementById('export-list')) { - loadExportJobs(); - setInterval(loadExportJobs, 10000); - } - if (document.getElementById('trash-list')) { - loadTrashList(); - } -}); - - -async function loadTrashList() { - const listEl = document.getElementById('trash-list'); - if (!listEl) return; - - listEl.innerHTML = `
${t('loading')}
`; - - try { - const res = await fetch('/api/v1/episodes?status=deleted&limit=200'); - const data = await res.json(); - const episodes = data.episodes || []; - - if (episodes.length === 0) { - listEl.innerHTML = `
${t('trash_empty')}
`; - } else { - const now = new Date(); - listEl.innerHTML = episodes.map(ep => { - const delTime = new Date(ep.deleted_at); - const daysLeft = Math.max(0, Math.ceil(7 - (now - delTime) / (1000 * 60 * 60 * 24))); - const time = new Date(ep.created_at).toLocaleString('zh-CN'); - return ` -
-
-
${ep.task_description || ep.name || 'unknown'}
-
- ${ep.fps}FPS · ${ep.frame_count}${t('frame')} · ${(ep.camera_names||[]).join(', ') || t('not_found')} -
-
${time}
-
-
${t('days_left')} ${daysLeft} ${t('day_unit')}
-
- - -
-
`; - }).join(''); - } - updateTrashBadge(); - } catch (err) { - listEl.innerHTML = `
${t('load_failed')}
`; - } -} - - -// updateTrashBadge 由 base.html 全局提供(所有页面生效),删除/恢复/清空后 -// 各操作函数调用它立即刷新徽标,无需刷新页面。 +/* Episode list, info panel, review/delete/export */ + +function escHtml(s) { + if (!s) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +let selectedEpisodes = new Set(); // batch selection +let allEpisodes = []; +let hierarchyData = []; // Project → Task → Episode tree for the list view +const _expandedProjects = new Set(); // expanded project ids (default: collapsed) +const _collapsedTasks = new Set(); // collapsed task ids +let _loadEpoch = 0; // stale-response guard — discard out-of-order poll results +let _formatEpoch = 0; // 导出格式徽标防竞态:快速切换批次时丢弃过期响应 +let _silentReloadTimer = null; // coalesce background list refreshes after actions +let _listLoadController = null; // abort obsolete hierarchy requests on fast navigation +let _episodesLoadedAt = 0; // snapshot freshness for background reconciliation +let selectMode = false; // selection mode toggle — hides checkboxes by default + +// 视图缓存。筛选结果只依赖 [筛选条件 + 排序模式 + 层级快照],与勾选状态无关; +// 但勾选一行原先要重算三遍(visibleEpisodes → hierarchy → updateBatchUI), +// 每次都 spread 复制全部 episode。缓存后勾选只改 Set 和那一行 DOM。 +// _hierarchyRev 在层级数据变化时自增 —— 快照替换、局部增删、局部改字段都要加。 +let _hierarchyRev = 0; +let _viewCache = { key: null, groups: [], visible: [], visibleIds: new Set(), reviewedIds: [] }; +// 上一次渲染所依据的服务端快照指纹;一致就跳过一次整表重绘。 +let _hierarchySignatureAt = null; + +// A short-lived cross-navigation snapshot makes the Review page paint +// immediately after leaving Projects/Workflow. The server remains +// authoritative: loadEpisodes still refreshes in the background. +const REVIEW_SNAPSHOT_CACHE_KEY = 'egodata.review.hierarchy.v1'; +const REVIEW_SNAPSHOT_CACHE_TTL = 60 * 1000; + +function readReviewSnapshotCache() { + try { + const cached = JSON.parse(sessionStorage.getItem(REVIEW_SNAPSHOT_CACHE_KEY) || 'null'); + if (!cached || !Array.isArray(cached.projects) + || Date.now() - Number(cached.savedAt || 0) > REVIEW_SNAPSHOT_CACHE_TTL) return null; + return cached; + } catch (_) { return null; } +} + +function saveReviewSnapshotCache(projects) { + try { + sessionStorage.setItem(REVIEW_SNAPSHOT_CACHE_KEY, JSON.stringify({ + savedAt: Date.now(), projects, + })); + } catch (_) { /* storage quota/private mode — network refresh still works */ } +} + +// 渲染只依赖这些字段;其余字段(如 duration、camera_streams)变了不影响卡片。 +function _episodeRenderSignature(ep) { + return [ep.id, ep.status, ep.ai_quality_status, ep._uiWorkflowState, + ep.frame_count, ep.fps, ep.name, ep.episode_index, + (ep.camera_names || []).length, ep.timestamp, ep.created_at].join(':'); +} + +function _hierarchySignature(projects) { + let parts = []; + (projects || []).forEach(node => { + parts.push(`P${node.project?.id}:${node.project?.name}`); + (node.episodes || []).forEach(ep => parts.push(_episodeRenderSignature(ep))); + }); + return parts.join(';'); +} + +function applyHierarchySnapshot(projects, savedAt = Date.now()) { + const incoming = projects || []; + // 15 秒轮询每次都会走到这里。卡片渲染只依赖上面那些字段,内容没变时 + // 直接返回 —— 否则用户正勾选着,列表被整表重建,会闪、丢焦点、丢点击。 + const signature = _hierarchySignature(incoming); + if (signature === _hierarchySignatureAt) { + _episodesLoadedAt = savedAt; + return; + } + _hierarchySignatureAt = signature; + hierarchyData = incoming; + _hierarchyRev++; + _episodesLoadedAt = savedAt; + allEpisodes = hierarchyData.flatMap(node => + (node.episodes || []).map(e => ({ ...e, task_description: node.project.name }))); + // updateTaskFilter 可能清掉已失效的 filter-task —— 必须在算视图之前调用, + // 否则 currentView() 会按一个已经不存在的筛选条件构建缓存。 + updateTaskFilter(visibleEpisodesForCurrentView()); + updateEpisodeListCount(allEpisodes, '', document.getElementById('filter-task')?.value || ''); + renderEpisodeCards(); +} + +// ── Nav tree → filter bridge (called from base.html) ── + +function applyNavFilter(statusParam, taskName) { + // Set hidden filter controls + const statusSel = document.getElementById('filter-status'); + const taskSel = document.getElementById('filter-task'); + const searchInput = document.getElementById('search-input'); + if (statusSel) statusSel.value = statusParam; + const requestedTask = taskName || ''; + if (taskSel) taskSel.value = requestedTask; + if (searchInput) searchInput.value = requestedTask; + // Update URL + const url = new URL(document.location); + url.searchParams.set('status', statusParam); + url.searchParams.set('search', requestedTask); + history.replaceState({}, '', url); + // Close detail panel (if open), then filter the in-memory snapshot. + backToList(); + if (hierarchyData.length) { + renderEpisodeListFromMemory(); + scheduleSilentEpisodeRefresh(250); + } else { + loadEpisodes(); + } + // Highlight nav tree + if (typeof window.highlightNavActive === 'function') { + window.highlightNavActive(statusParam, taskName); + } +} +window.applyNavFilter = applyNavFilter; + +function syncReviewShortcutActive(status) { + const key = status === 'reviewed' ? 'approved' + : (status === 'failed' ? 'failed' : 'reviewing'); + document.querySelectorAll('.sidebar-sub[data-review-status]').forEach(link => { + link.classList.toggle('active', link.dataset.reviewStatus === key); + }); +} + +function switchReviewStatus(status, options = {}) { + const allowed = new Set(['completed', 'to_review', 'reviewed', 'failed']); + const nextStatus = allowed.has(status) ? status : 'completed'; + const statusSel = document.getElementById('filter-status'); + const taskSel = document.getElementById('filter-task'); + const searchInput = document.getElementById('search-input'); + if (statusSel) statusSel.value = nextStatus; + if (taskSel) taskSel.value = ''; + if (searchInput) searchInput.value = ''; + + const url = new URL(window.location.href); + url.searchParams.set('status', nextStatus); + url.searchParams.delete('search'); + if (options.replace) history.replaceState({}, '', url); + else history.pushState({}, '', url); + + syncReviewShortcutActive(nextStatus); + backToList(); + if (hierarchyData.length) { + renderEpisodeListFromMemory(); + // Keep the switch instant; only reconcile in the background when the + // current snapshot is older than a moment. + if (Date.now() - _episodesLoadedAt > 1000) scheduleSilentEpisodeRefresh(200); + } else { + loadEpisodes(); + } +} +window.switchReviewStatus = switchReviewStatus; + +function installReviewStatusNavigation() { + if (window.location.pathname !== '/review') return; + const statusByShortcut = { reviewing: 'completed', approved: 'reviewed', failed: 'failed' }; + document.querySelectorAll('.sidebar-sub[data-review-status]').forEach(link => { + link.addEventListener('click', event => { + event.preventDefault(); + switchReviewStatus(statusByShortcut[link.dataset.reviewStatus] || 'completed'); + }); + }); + window.addEventListener('popstate', () => { + const params = new URLSearchParams(window.location.search); + const status = params.get('status') || 'completed'; + const statusSel = document.getElementById('filter-status'); + const searchInput = document.getElementById('search-input'); + if (statusSel) statusSel.value = status; + if (searchInput) searchInput.value = params.get('search') || ''; + syncReviewShortcutActive(status); + if (hierarchyData.length) renderEpisodeListFromMemory(); + else loadEpisodes(); + }); +} + +// ── Init from URL param ───────────────────────────── + +function initStatusFromURL() { + const params = new URLSearchParams(document.location.search); + const statusParam = params.get('status') || 'completed'; + const sel = document.getElementById('filter-status'); + if (sel) sel.value = statusParam; + + const searchParam = params.get('search') || ''; + const searchInput = document.getElementById('search-input'); + if (searchInput && searchParam) searchInput.value = searchParam; +} + +// ── Sort order ────────────────────────────────────── + +// label 存 **i18n key** 而不是文案 —— 这个标签会被 toggleSortOrder() 直接写进 +// DOM,写死英文的话切了中文后点一下排序又变回英文。 +const SORT_MODES = [ + { dir: 'asc', labelKey: 'name_1_to_n', icon: 'ant-design:ordered-list-outlined' }, + { dir: 'desc', labelKey: 'name_n_to_1', icon: 'ant-design:ordered-list-outlined' }, +]; +let sortModeIdx = 0; // cycles 0→1→2→3→0 + +function currentSortMode() { + return SORT_MODES[sortModeIdx]; +} + +function toggleSortOrder() { + sortModeIdx = (sortModeIdx + 1) % SORT_MODES.length; + const mode = currentSortMode(); + const icon = document.getElementById('sort-order-icon'); + const label = document.getElementById('sort-order-label'); + const btn = document.getElementById('btn-sort-order'); + if (icon) icon.setAttribute('icon', mode.icon); + if (label) label.textContent = t(mode.labelKey); + if (btn) btn.title = t('sort_label') + t(mode.labelKey); + // Re-sort in memory (no network round trip) — only refetch if not yet loaded + if (hierarchyData.length === 0) { + loadEpisodes(); + } else { + renderEpisodeListFromMemory(); + } +} + +// Extract trailing numeric suffix: "Chew_gum_0005" → 5, "ep_00020" → 20, "no_number" → 0 +function extractEpisodeNumber(name) { + const m = String(name || '').match(/(\d+)\s*$/); + return m ? parseInt(m[1], 10) : 0; +} + +// ── Selection mode ────────────────────────────────── + +function toggleSelectMode() { + selectMode = !selectMode; + if (!selectMode) { + // 退出选择模式:清空选择,但**不动项目的展开/折叠状态** —— + // 用户展开过哪些项目是他自己的布局,不该被模式切换重置。 + selectedEpisodes.clear(); + } + // 进入选择模式**不再自动展开所有项目**。项目行本身就有三态全选框, + // 折叠状态下点一下即可选中整批(卡片仍在 DOM 里),所以没有展开的必要; + // 自动展开会让几十个项目同时铺开,列表瞬间变长、滚动位置也丢了。 + // 退出时的按钮态与批量栏显隐统一交给 updateBatchUI。 + renderEpisodeListFromMemory(); +} + +// ── Load ──────────────────────────────────────────── + +function currentReviewStatus() { + return document.getElementById('filter-status')?.value || 'completed'; +} + +function isEpisodeTemporarilyHidden(ep) { + // Reprocessing invalidates the previous review artifact immediately. Keep + // the episode out of every list until the server publishes the new state; + // otherwise the old card can remain visible as "Processing…". + return Boolean(ep && (ep.status === 'processing' + || ep._uiWorkflowState === 'queued')); +} + +function matchesReviewStatus(status, filter = currentReviewStatus()) { + if (!filter) return true; + if (filter === 'completed' || filter === 'to_review') { + if (status === 'completed' || status === 'to_review') return true; + // processing 由 hierarchyForCurrentView 统一隐藏,避免用户打开 + // 旧产物;处理完成后由下一次静默刷新重新出现。 + return false; + } + if (filter === 'reviewed') { + return status === 'reviewed' || status === 'approved'; + } + return status === filter; +} + +function matchesEpisodeSearch(ep, projectName, rawSearch) { + const search = String(rawSearch || '').trim().toLowerCase(); + if (!search) return true; + return (ep.name || '').toLowerCase().includes(search) + || String(ep.id || '').toLowerCase().includes(search) + || String(projectName || '').toLowerCase().includes(search); +} + +// The server snapshot contains all live episode metadata. Status/search +// filtering happens here, so Reviewing ↔ Approved never starts another scan. +function hierarchyForCurrentView() { + const status = currentReviewStatus(); + const search = document.getElementById('search-input')?.value || ''; + return hierarchyData.map(node => ({ + ...node, + episodes: (node.episodes || []).filter(ep => + !isEpisodeTemporarilyHidden(ep) + && matchesReviewStatus(ep.status, status) + && matchesEpisodeSearch(ep, node.project?.name, search)), + })); +} + +// 缓存后的视图模型:分组(已按 task 筛选并按当前排序模式排好)+ 扁平的 +// 可见 / 已审核 id 列表。渲染、勾选、计数全部读这一份,不再各自重算。 +function currentView() { + const status = currentReviewStatus(); + const search = document.getElementById('search-input')?.value || ''; + const taskFilter = document.getElementById('filter-task')?.value || ''; + const dir = currentSortMode().dir === 'asc' ? 1 : -1; + const key = [_hierarchyRev, status, search, taskFilter, dir].join('|'); + if (_viewCache.key === key) return _viewCache; + + const groups = hierarchyForCurrentView() + .filter(node => !taskFilter || (node.project?.name || '') === taskFilter) + .map(node => ({ + project: node.project, + // 组内按尾部序号排序(Test1_000012 → 12),只排副本不动原始数据 + episodes: [...(node.episodes || [])].sort((a, b) => + (extractEpisodeNumber(a.name) - extractEpisodeNumber(b.name)) * dir), + })); + + const visible = []; + const visibleIds = new Set(); + const reviewedIds = []; + const framesById = new Map(); + groups.forEach(group => { + group.episodes.forEach(ep => { + visible.push({ ...ep, task_description: group.project?.name }); + visibleIds.add(String(ep.id)); + framesById.set(String(ep.id), Number(ep.frame_count) || 0); + if (ep.status === 'reviewed' || ep.status === 'approved') { + reviewedIds.push(String(ep.id)); + } + }); + }); + _viewCache = { key, groups, visible, visibleIds, reviewedIds, framesById }; + return _viewCache; +} + +function visibleEpisodesForCurrentView() { + return currentView().visible; +} + +async function loadEpisodes(options = {}) { + const silent = Boolean(options.silent); + if (!silent && _silentReloadTimer) { + clearTimeout(_silentReloadTimer); + _silentReloadTimer = null; + } + const taskFilter = document.getElementById('filter-task')?.value || ''; + const listEl = document.getElementById('episode-list-inline'); + if (!listEl) return; + + // Paint the last successful hierarchy immediately on a new page load. + // A normal request below still reconciles status/uploads in the background. + if (!silent && hierarchyData.length === 0) { + const cached = readReviewSnapshotCache(); + if (cached) applyHierarchySnapshot(cached.projects, Number(cached.savedAt) || Date.now()); + } + + // Save scroll position before innerHTML rebuild (prevent 15s poll reset) + const scrollContainer = document.getElementById('episode-list-section'); + const savedScrollTop = scrollContainer ? scrollContainer.scrollTop : 0; + + if (!silent && hierarchyData.length === 0) { + listEl.innerHTML = `
${t('loading')}
`; + } + + if (_listLoadController) _listLoadController.abort(); + const controller = new AbortController(); + _listLoadController = controller; + const fetchEpoch = ++_loadEpoch; + try { + // Hierarchy view: Project → Task (upload batch) → Episodes. + // Status and search are applied after the full snapshot arrives. + + // ``fetchEpoch`` was captured before dispatch so stale responses are ignored. + const res = await fetch('/api/v1/projects/hierarchy', { signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (fetchEpoch !== _loadEpoch) return; // newer request in flight — discard + saveReviewSnapshotCache(data.projects || []); + applyHierarchySnapshot(data.projects || []); + // Restore scroll position after render + if (scrollContainer) { + requestAnimationFrame(() => { scrollContainer.scrollTop = savedScrollTop; }); + } + } catch (err) { + if (err?.name === 'AbortError') return; + if (!silent && hierarchyData.length === 0) { + listEl.innerHTML = `
${t('load_failed')}: ${err.message}
`; + } + } finally { + if (_listLoadController === controller) _listLoadController = null; + } +} + + +function updateEpisodeListCount(episodes, search, taskFilter) { + const countEl = document.getElementById('episode-list-count'); + if (!countEl) return; + let filtered = episodes.filter(ep => !isEpisodeTemporarilyHidden(ep) + && matchesReviewStatus(ep.status)); + if (taskFilter) filtered = filtered.filter(ep => (ep.task_description || '') === taskFilter); + if (search) { + const s = search.toLowerCase(); + filtered = filtered.filter(ep => + (ep.name || '').toLowerCase().includes(s) || + (ep.task_description || '').toLowerCase().includes(s) || + String(ep.id).toLowerCase().includes(s)); + } + countEl.textContent = filtered.length + ' episodes'; +} + +function updateTaskFilter(episodes) { + // filter-task is now a hidden input (not a select). Values are set by applyNavFilter(). + // This function remains as a hook: clear the value if the task no longer exists in results. + const sel = document.getElementById('filter-task'); + if (!sel) return; + const current = sel.value; + if (current) { + const taskNames = new Set(episodes.map(e => e.task_description || 'unknown')); + if (!taskNames.has(current)) { + sel.value = ''; + } + } +} + + +function renderEpisodeCards() { + const listEl = document.getElementById('episode-list-inline'); + if (!listEl) return; + + // Auto-exit selection mode when switching away from Reviewed + const statusFilter = document.getElementById('filter-status')?.value || ''; + if (selectMode && statusFilter !== 'reviewed') { + selectMode = false; + selectedEpisodes.clear(); + } + + // 分组、筛选、排序都来自缓存的视图模型 —— 渲染只负责拼 HTML。 + const view = currentView(); + + // 项目文件夹**始终渲染**(含空项目/刚创建的项目):几个项目就显示几个 + // 文件夹,哪怕里面没有批次;只有没有任何项目时才显示 No data。 + if (view.groups.length === 0) { + listEl.innerHTML = `
${t('no_data')}
`; + updateBatchUI(); + return; + } + + // 任务概念已移除:项目 → Episodes 两层(组内排序已在 currentView 完成) + let html = ''; + view.groups.forEach((group) => { + const sorted = group.episodes; + const projectId = String(group.project?.id ?? ''); + // 默认折叠:只在用户点击展开过的项目才展开视频卡片 + const pCollapsed = !_expandedProjects.has(group.project?.id); + html += ` +
+
+ ${projectSelectBoxHtml(projectId, sorted)} + + ${escHtml(group.project?.name || '')} + ${sorted.length} + + +
+
`; + if (sorted.length === 0) { + html += `
${t('no_tasks_in_project')}
`; + } + sorted.forEach(ep => { html += episodeCardHtml(ep, group.project?.name); }); + html += `
`; + }); + listEl.innerHTML = html; + syncProjectSelectDom(); + updateBatchUI(); +} + +// 项目行左侧的三态全选框。只作用于当前筛选下该项目可见的已审核批次; +// 折叠状态照常可点 —— 卡片仍在 DOM 里,只是 .project-body 被 hidden, +// 所以点一下就能选中折叠中的整批,不必先展开。 +function projectSelectBoxHtml(projectId, episodes) { + const statusFilter = document.getElementById('filter-status')?.value || ''; + if (!selectMode || statusFilter !== 'reviewed') return ''; + const reviewed = episodes.filter(ep => ep.status === 'reviewed' || ep.status === 'approved'); + const disabled = reviewed.length === 0 ? ' disabled' : ''; + return ``; +} + +// 该项目在当前视图下可见的、可勾选的批次 id。 +function projectReviewedIds(projectId) { + const group = currentView().groups.find( + item => String(item.project?.id ?? '') === String(projectId)); + if (!group) return []; + return group.episodes + .filter(ep => ep.status === 'reviewed' || ep.status === 'approved') + .map(ep => String(ep.id)); +} + +function toggleProjectSelect(projectId, checked) { + projectReviewedIds(projectId).forEach(id => { + if (checked) selectedEpisodes.add(id); + else selectedEpisodes.delete(id); + }); + syncProjectSelectionDom(projectId); + updateBatchUI(); +} + +// 把选中状态同步回 DOM:项目行三态框 + 该项目下每张卡片。只碰受影响的项目, +// 不重建整个列表 —— 这是勾选不再"闪一下"的关键。 +function syncProjectSelectionDom(onlyProjectId) { + document.querySelectorAll('#episode-list-inline .project-group').forEach(group => { + if (onlyProjectId !== undefined + && String(group.dataset.projectId) !== String(onlyProjectId)) return; + group.querySelectorAll('input.batch-checkbox').forEach(box => { + box.checked = selectedEpisodes.has(String(box.dataset.episodeId)); + box.closest('.episode-card')?.classList.toggle('selected', box.checked); + }); + syncProjectSelectBox(group); + }); +} + +function syncProjectSelectBox(group) { + const box = group.querySelector('.project-checkbox'); + if (!box) return; + const ids = projectReviewedIds(box.dataset.projectId); + const selected = ids.filter(id => selectedEpisodes.has(id)).length; + box.checked = ids.length > 0 && selected === ids.length; + box.indeterminate = selected > 0 && selected < ids.length; + const badge = group.querySelector('.project-select-count'); + if (badge) badge.textContent = selected > 0 ? `${selected}/${ids.length}` : ''; +} + +function syncProjectSelectDom() { + syncProjectSelectionDom(); +} + +function toggleProjectCollapse(projectId) { + if (_expandedProjects.has(projectId)) _expandedProjects.delete(projectId); + else _expandedProjects.add(projectId); + // 只切这一个项目的 body 与箭头:整表重绘会丢掉滚动位置和焦点, + // 大列表上肉眼可见地闪。 + document.querySelectorAll('#episode-list-inline .project-group').forEach(group => { + if (String(group.dataset.projectId) !== String(projectId)) return; + const collapsed = !_expandedProjects.has(projectId); + group.querySelector('.project-body')?.classList.toggle('hidden', collapsed); + group.querySelector('.project-caret') + ?.setAttribute('icon', collapsed ? 'ant-design:right-outlined' : 'ant-design:down-outlined'); + }); +} + +// Apply small action results locally so the list responds immediately. The +// server remains authoritative; a coalesced silent refresh reconciles state +// after the filesystem-backed hierarchy has caught up. +function renderEpisodeListFromMemory() { + // updateTaskFilter 可能清掉已失效的 filter-task,所以要在取视图之前调用。 + updateTaskFilter(visibleEpisodesForCurrentView()); + updateEpisodeListCount(allEpisodes, '', document.getElementById('filter-task')?.value || ''); + renderEpisodeCards(); +} + +function removeEpisodeFromLocalList(episodeId) { + const id = String(episodeId); + selectedEpisodes.delete(id); + _hierarchyRev++; + hierarchyData.forEach(node => { + node.episodes = (node.episodes || []).filter(ep => String(ep.id) !== id); + }); + allEpisodes = allEpisodes.filter(ep => String(ep.id) !== id); + renderEpisodeListFromMemory(); +} + +function updateEpisodeInLocalList(episodeId, patch) { + const id = String(episodeId); + _hierarchyRev++; + hierarchyData.forEach(node => { + const ep = (node.episodes || []).find(item => String(item.id) === id); + if (ep) Object.assign(ep, patch); + }); + const flat = allEpisodes.find(ep => String(ep.id) === id); + if (flat) Object.assign(flat, patch); + renderEpisodeListFromMemory(); +} + +function scheduleSilentEpisodeRefresh(delay = 1200) { + clearTimeout(_silentReloadTimer); + _silentReloadTimer = setTimeout(() => { + _silentReloadTimer = null; + loadEpisodes({ silent: true }); + }, delay); +} + +function toggleTaskCollapse(taskId) { + if (_collapsedTasks.has(taskId)) _collapsedTasks.delete(taskId); + else _collapsedTasks.add(taskId); + renderEpisodeListFromMemory(); +} + +function episodeCardHtml(ep, taskName) { + const activeClass = currentEpisodeId === ep.id ? 'active' : ''; + // 前端入队标记(点击后立即显示)或后端真实状态(静默刷新后保持显示) + const isWorkflowQueued = ep._uiWorkflowState === 'queued' || ep.status === 'processing'; + // AI 标注运行中:整批数据等标注完成后才允许查看 + const isAiAnnotating = !isWorkflowQueued && ep.ai_quality_status === 'running'; + const cardClass = (isWorkflowQueued || isAiAnnotating) + ? 'cursor-default opacity-80' + : 'cursor-pointer'; + const cardClick = (isWorkflowQueued || isAiAnnotating) + ? '' : `onclick="selectEpisode('${ep.id}')"`; + const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; + const isFailed = ep.status === 'failed'; + const statusDot = (isWorkflowQueued || isAiAnnotating) + ? '' + : (isFailed + ? '' + : (isReviewed + ? '' + : '')); + const statusText = isWorkflowQueued ? t('processing') + : (isAiAnnotating ? t('ai_annotating') + : (isFailed ? t('stat_failed') : (isReviewed ? t('reviewed') : t('reviewing')))); + const statusColor = (isWorkflowQueued || isAiAnnotating) ? 'text-blue-400' + : (isFailed ? 'text-red-400' : (isReviewed ? 'text-green-400' : 'text-yellow-400')); + const cameraCount = (ep.camera_names || []).length; + const timestamp = ep.timestamp || ''; + const time = timestamp ? timestamp : new Date(ep.created_at).toLocaleDateString('zh-CN'); + + const checked = selectedEpisodes.has(ep.id) ? 'checked' : ''; + const checkbox = (isReviewed && selectMode) + ? `` + : ''; + + const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation'; + const statusFilter = document.getElementById('filter-status')?.value + || new URLSearchParams(document.location.search).get('status') + || 'completed'; + const isReviewingMode = statusFilter === 'completed' || statusFilter === 'to_review'; + const userRole = String(window.__EGO_USER__?.role || '').toLowerCase(); + const canReprocess = userRole === 'admin' || userRole === 'engineer'; + // processing(含前端入队标记)显示禁用的"处理中"状态按钮,不要求角色; + // 主动重跑按钮仍只对 admin/engineer 开放且要求 completed/to_review。 + const workflowButton = !isAnnotationPage && isReviewingMode + ? (isWorkflowQueued + ? `` + : (canReprocess && (ep.status === 'completed' || ep.status === 'to_review') + ? `` + : '')) + : ''; + const buttons = isAnnotationPage ? '' : (isFailed + ? `
+ + +
` + : (isReviewed + ? `
+ + +
` + : `
+ + +
${workflowButton}`)); + + return ` +
+ ${checkbox} +
+
+ ${statusDot} + ${statusText} +
+
+ ${typeof ep.episode_index === 'number' + ? `
#${ep.episode_index}
` + : `
${ep.name || taskName || ep.id.slice(0, 8)}
`} +
+
+
${ep.fps || 30}FPS · ${ep.frame_count || 0}${t('frame')}
+ ${cameraCount > 0 ? `
${cameraCount} ${t('cameras')}
` : ''} +
${time}
+
+ ${buttons} +
+
`; +} + + +// ── Back to list ───────────────────────────────────── + +function backToList() { + // Closing detail must release decoders, 3D canvases, timers, sockets and + // in-flight media requests. Hiding the panel alone leaves the old video + // workload alive behind Reviewing/Approved and makes the next click lag. + if (typeof clearEpisodeWorkspace === 'function') clearEpisodeWorkspace(); + const detail = document.getElementById('episode-detail'); + const listSection = document.getElementById('episode-list-section'); + if (detail) detail.classList.add('hidden'); + if (listSection) listSection.classList.remove('hidden'); + // 返回批次列表 → 隐藏漂浮预览控件(点开视频后才有) + if (typeof setPreviewPanelsVisible === 'function') setPreviewPanelsVisible(false); + const warning = document.getElementById('input-warning-banner'); + if (warning) { + warning.classList.add('hidden'); + warning.innerHTML = ''; + } +} + +function renderInputWarning(ep) { + const warning = document.getElementById('input-warning-banner'); + if (!warning) return; + const missing = []; + const matched = []; + (ep.exceptions || []).filter(ex => ex.kind === 'input_missing').forEach(ex => { + (ex.missing || []).forEach(value => { + if (!missing.includes(String(value))) missing.push(String(value)); + }); + (ex.matched || []).forEach(value => { + if (!matched.includes(String(value))) matched.push(String(value)); + }); + }); + if (!missing.length) { + warning.classList.add('hidden'); + warning.innerHTML = ''; + return; + } + warning.innerHTML = + '
' + + '' + + '
' + + '
' + escHtml(t('input_missing_title')) + '
' + + '
' + escHtml(t('input_missing_detail')) + '
' + + '
' + escHtml(missing.join(', ')) + '
' + + (matched.length ? '
✓ ' + escHtml(matched.join(', ')) + '
' : '') + + '
' + + '
'; + warning.classList.remove('hidden'); +} + +// ── Batch selection ────────────────────────────────── + +function toggleBatchSelect(episodeId, checked) { + const id = String(episodeId); + if (checked) selectedEpisodes.add(id); + else selectedEpisodes.delete(id); + // 只同步这一行与它所属项目的三态框 —— 不重算筛选、不重建列表。 + const card = document.querySelector( + `#episode-list-inline .episode-card[data-episode-id="${CSS.escape(id)}"]`); + card?.classList.toggle('selected', checked); + const group = card?.closest('.project-group'); + if (group) syncProjectSelectBox(group); + updateBatchUI(); +} + + +function toggleSelectAll(checked) { + if (!selectMode) return; + if ((document.getElementById('filter-status')?.value || '') !== 'reviewed') return; + + currentView().reviewedIds.forEach(id => { + if (checked) selectedEpisodes.add(id); + else selectedEpisodes.delete(id); + }); + // 直接写已渲染的 checkbox,不重建整个列表。 + document.querySelectorAll('#episode-list-inline input.batch-checkbox').forEach(box => { + box.checked = selectedEpisodes.has(String(box.dataset.episodeId)); + box.closest('.episode-card')?.classList.toggle('selected', box.checked); + }); + syncProjectSelectDom(); + updateBatchUI(); +} + + +function updateBatchUI() { + const selectAllBar = document.getElementById('select-all-bar'); + const batchBar = document.getElementById('batch-bar'); + const selectBtn = document.getElementById('btn-toggle-select'); + const statusFilter = document.getElementById('filter-status')?.value || ''; + const isReviewedMode = statusFilter === 'reviewed'; + const showSelection = selectMode && isReviewedMode; + + // 「Select」只在非选择模式出现 —— 进入选择模式后由批量栏里的 Cancel 接管。 + if (selectBtn) { + selectBtn.classList.toggle('hidden', !isReviewedMode || selectMode); + } + // Force-exit selection mode when switching away from Reviewed + if (!isReviewedMode && selectMode) { + selectMode = false; + selectedEpisodes.clear(); + } + + if (selectAllBar) { + selectAllBar.classList.toggle('hidden', !showSelection); + } + // 批量栏只要在选择模式就显示(哪怕一集都没选)—— 否则 Cancel 没地方放。 + if (batchBar) { + batchBar.classList.toggle('hidden', !showSelection); + } + if (!showSelection) { + if (!selectMode) selectedEpisodes.clear(); + return; + } + + // Update counts + const view = currentView(); + const reviewedCount = view.reviewedIds.length; + const selCount = view.reviewedIds.filter(id => selectedEpisodes.has(id)).length; + + const selectCount = document.getElementById('select-count'); + if (selectCount) selectCount.textContent = `(${selCount}/${reviewedCount})`; + + const selectAllCb = document.getElementById('select-all-checkbox'); + if (selectAllCb) { + selectAllCb.checked = reviewedCount > 0 && selCount === reviewedCount; + selectAllCb.indeterminate = selCount > 0 && selCount < reviewedCount; + } + + // 选中规模:让用户在点之前知道这一下要跑多久 —— 打包下载是秒级原样 zip, + // 导出数据集是整库重建(实测约 30 秒/集)。 + let frames = 0; + view.reviewedIds.forEach(id => { + if (selectedEpisodes.has(id)) frames += view.framesById.get(id) || 0; + }); + const batchCount = document.getElementById('batch-count'); + if (batchCount) { + batchCount.textContent = t('batch_selected_summary') + .replace('%s', String(selCount)) + .replace('%f', frames.toLocaleString()); + } + const exportBtnLabel = document.getElementById('batch-export-label'); + if (exportBtnLabel) { + const minutes = Math.max(1, Math.round(selCount * 30 / 60)); + exportBtnLabel.textContent = selCount >= 5 + ? `${t('batch_export_dataset')} ~${minutes}${t('minutes_short')}` + : t('batch_export_dataset'); + } + // 一集都没选时两个动作按钮禁用(Cancel 仍可点,否则退不出去)。 + const exportBtn = document.getElementById('batch-export-btn'); + if (exportBtn) exportBtn.disabled = selCount === 0; + const zipBtn = document.getElementById('batch-zip-btn'); + if (zipBtn) zipBtn.disabled = selCount === 0; +} + + +// 导出数据集:合并成一个可训练数据集。整库重建 —— 每路视频重编码、 +// 逐帧重采样、全量重算 stats,实测约 30 秒/集。 +async function batchDownload(button = null) { + if (selectedEpisodes.size === 0) return; + const ids = Array.from(selectedEpisodes); + const done = await startReviewExport(ids, null, null, button); + if (done && selectMode) toggleSelectMode(); +} + +// 打包下载:每集一个目录原样 zip,不重建,秒级返回。 +// 用原生表单提交而非 fetch + blob —— 浏览器自己流式写盘,不会把整个 +// zip 缓冲进内存(几十集的包很容易上 GB)。 +function batchZipDownload(button = null) { + if (selectedEpisodes.size === 0) return; + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/api/v1/export/batch-download'; + form.style.display = 'none'; + const field = document.createElement('input'); + field.type = 'hidden'; + field.name = 'episode_ids'; + field.value = Array.from(selectedEpisodes).join(','); + form.appendChild(field); + document.body.appendChild(form); + form.submit(); + form.remove(); + if (button) button.blur(); +} + + +function waitForExportJob(jobId, button) { + const originalHtml = button ? button.innerHTML : null; + return new Promise(resolve => { + const poll = async () => { + try { + const res = await fetch(`/api/v1/export/${jobId}`); + const job = await res.json(); + if (job.status === 'completed') { + resolve(true); + return; + } + if (job.status === 'failed') { + alert('Export failed: ' + (job.error || 'unknown')); + resolve(false); + return; + } + if (button) { + // 导出期间按钮直接显示百分比进度,不再静默等待 + const pct = Math.round((Number(job.progress) || 0) * 100); + button.textContent = 'Exporting… ' + pct + '%'; + } + } catch (err) { + alert('Export status failed: ' + err.message); + resolve(false); + return; + } + setTimeout(poll, 800); + }; + poll(); + }).finally(() => { + if (button) { + if (originalHtml !== null) button.innerHTML = originalHtml; + button.disabled = false; + button.dataset.exporting = ''; + button.classList.remove('opacity-60', 'cursor-wait'); + } + }); +} + + +async function startReviewExport(ids, format, datasetName, button = null) { + if (!ids || ids.length === 0) return false; + if (button) { + button.disabled = true; + button.dataset.exporting = '1'; + button.classList.add('opacity-60', 'cursor-wait'); + } + try { + const payload = { + episode_ids: ids, + split_ratio: 0.9, + }; + if (datasetName) payload.dataset_name = datasetName; + // Normal review-page exports omit the format deliberately: the + // backend resolves it from the selected project's workflow node. + if (format) payload.export_format = format; + const res = await fetch('/api/v1/export/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert('Export failed: ' + (err.detail || 'unknown')); + if (button) { + button.disabled = false; + button.dataset.exporting = ''; + button.classList.remove('opacity-60', 'cursor-wait'); + } + return false; + } + const job = await res.json(); + const done = await waitForExportJob(job.id, button); + if (done) { + window.location.href = `/api/v1/export/download/${job.id}`; + return true; + } + return false; + } catch (err) { + alert('Export failed: ' + err.message); + if (button) { + button.disabled = false; + button.dataset.exporting = ''; + button.classList.remove('opacity-60', 'cursor-wait'); + } + return false; + } +} + + +// ── Select / Info Panel ────────────────────────────── + +function showAiPendingGate(episodeId) { + // 全屏等待遮罩:AI 标注未完成时批次数据不可查看。每 4 秒轮询层级 + // 接口,标注结束(非 running)后自动移除遮罩并重新进入批次。 + const listSection = document.getElementById('episode-list-section'); + const detail = document.getElementById('episode-detail'); + if (listSection) listSection.classList.add('hidden'); + if (detail) detail.classList.remove('hidden'); + let overlay = document.getElementById('ai-pending-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'ai-pending-overlay'; + overlay.className = 'fixed inset-0 z-[999] flex flex-col items-center justify-center gap-3 bg-black/85'; + document.body.appendChild(overlay); + } + overlay.innerHTML = ` + + +
${t('ai_annotating')}
+
${t('ai_annotating_hint')}
`; + if (overlay.dataset.polling === '1') return; + overlay.dataset.polling = '1'; + (async function poll() { + for (let i = 0; i < 300; i++) { // 最多约 20 分钟 + await new Promise(resolve => setTimeout(resolve, 4000)); + try { + const res = await fetch('/api/v1/projects/hierarchy'); + if (!res.ok) continue; + const data = await res.json(); + const projects = Array.isArray(data) + ? data : (data && data.projects) || []; + let done = false; + for (const projectNode of projects) { + const hit = (projectNode.episodes || []) + .find(e => e.id === episodeId); + if (!hit) continue; + if (hit.ai_quality_status !== 'running') { + done = true; // passed / failed / null → 可查看 + } else if (hit.status === 'processing') { + done = true; // 回到 processing,由现有门禁接管 + } + break; + } + if (done) { + overlay.dataset.polling = ''; + overlay.remove(); + if (typeof selectEpisode === 'function') { + selectEpisode(episodeId); + } + return; + } + } catch (_) { /* 网络抖动,继续轮询 */ } + } + overlay.dataset.polling = ''; + overlay.remove(); + if (typeof backToList === 'function') backToList(); + })(); +} + +function selectEpisode(episodeId) { + const ep = allEpisodes.find(e => e.id === episodeId); + if (!ep) return; + // Reprocessing keeps the list card visible, but its previous artifacts are + // no longer a valid review target. Do not reopen stale media while the + // worker is replacing the processed output. + if (ep.status === 'processing' || ep._uiWorkflowState === 'queued') { + backToList(); + return; + } + // AI 标注运行中:整批数据等标注完成后才显示(全屏等待遮罩 + 轮询, + // 完成后自动载入)。 + if (ep.ai_quality_status === 'running') { + showAiPendingGate(episodeId); + return; + } + + // Annotation is a separate workspace: start every selected file with a + // clean editor while leaving the review page's approval flow untouched. + if (window.EGODATA_PAGE_MODE === 'annotation' && typeof hideAnnotationForm === 'function') { + hideAnnotationForm(); + } + + // Highlight card + document.querySelectorAll('.episode-card').forEach(el => el.classList.remove('active')); + const card = document.querySelector('.episode-card[data-episode-id="' + episodeId + '"]'); + if (card) card.classList.add('active'); + + // Load video + const cameras = ep.camera_names || []; + const playbackMeta = { frameCount: ep.frame_count, fps: ep.fps }; + if (typeof loadGroupedEpisodeVideo === 'function') { + loadGroupedEpisodeVideo(episodeId, cameras, { + hasSkeleton: Boolean(ep.has_skeleton), + ...playbackMeta, + }); + } else { + loadEpisodeVideo(episodeId, cameras, playbackMeta); + } + + // Switch to detail view: hide list, show detail panel + const listSection = document.getElementById('episode-list-section'); + const detail = document.getElementById('episode-detail'); + if (listSection) listSection.classList.add('hidden'); + if (detail) detail.classList.remove('hidden'); + // 进入批次详情(审核/通过/标注页)→ 显示漂浮预览控件 + if (typeof setPreviewPanelsVisible === 'function') setPreviewPanelsVisible(true); + renderInputWarning(ep); + + // Fill detail header + const nameEl = document.getElementById('detail-episode-name'); + if (nameEl) { + const episodeNumber = typeof ep.episode_index === 'number' + ? `#${ep.episode_index}` : ''; + const title = ep.task_description || ep.name || ep.id.slice(0, 8); + nameEl.textContent = episodeNumber ? `${episodeNumber} · ${title}` : title; + nameEl.title = ep.id || title; + } + const statusBadge = document.getElementById('detail-status-badge'); + if (statusBadge) { + const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; + const isFailed = ep.status === 'failed'; + const isProcessing = ep.status === 'processing'; + statusBadge.className = 'text-xs flex-shrink-0 ' + (isFailed ? 'text-red-400' : (isReviewed ? 'text-green-400' : (isProcessing ? 'text-blue-400' : 'text-yellow-400'))); + statusBadge.textContent = isFailed ? t('stat_failed') : (isReviewed ? t('reviewed') : (isProcessing ? t('processing') : t('reviewing'))); + } + + // Fill detail info + const infoDiv = document.getElementById('episode-detail-info'); + const meta = ep.meta || {}; + const timestamp = meta.timestamp || ''; + const time = timestamp || new Date(ep.created_at).toLocaleString('zh-CN'); + const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; + const isFailed = ep.status === 'failed'; + const duration = ep.fps > 0 ? (ep.frame_count / ep.fps).toFixed(1) : '0'; + const cameraList = (ep.camera_names || []).join(', '); + + let cleaningHTML = ''; + if (ep.cleaning_report) { + const cr = ep.cleaning_report; + if (cr.passed) { + cleaningHTML = '
Cleaning passed
'; + } else { + const failList = (cr.checks || []).filter(c => !c.passed).map(c => c.name).join(', '); + cleaningHTML = '
Cleaning failed
'; + } + } + + // 上传不匹配/运行失败异常:点击文件后在此详情显示(不在项目列表显示) + let exceptionHTML = ''; + if (ep.exceptions && ep.exceptions.length) { + exceptionHTML = ep.exceptions.map(function (ex) { + const kindLabel = ex.kind === 'run_failed' + ? t('exception_kind_failed') + : (ex.kind === 'input_missing' ? t('exception_kind_missing') : t('exception_kind_mismatch')); + const safeMsg = escHtml(ex.message || ''); + const msg = safeMsg ? ': ' + safeMsg : ''; + const color = ex.kind === 'input_missing' ? 'text-amber-300' : 'text-red-400'; + return '
' + + ' ' + kindLabel + msg + '
'; + }).join(''); + } + + const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation'; + const buttons = isAnnotationPage ? '' : (isFailed + ? '' + + '' + : (ep.status === 'processing' + ? '' + + '' + : (isReviewed + ? '' + + '' + : '' + + ''))); + + if (infoDiv) { + infoDiv.innerHTML = + '
' + + (cleaningHTML || '') + + (exceptionHTML || '') + + '
' + + '
' + ep.frame_count + ' frames · ' + ep.fps + ' FPS · ' + duration + 's
' + + (cameraList ? '
' + cameraList + '
' : '') + + '
' + time + '
' + + '' + + '' + + '
' + + '
' + + 'Frame' + + '-' + + '/ ' + ep.frame_count + '' + + '
' + + '
' + buttons + '
' + + '
' + + '
'; + if (typeof updateEpisodeDetailSyncStatus === 'function') updateEpisodeDetailSyncStatus(); + + // 导出格式徽标(工作流连接驱动:HDF5 / LeRobot v2.1 / v3.0 / Raw 兜底) + const fmtEl = document.getElementById('episode-detail-format'); + if (fmtEl) { + fmtEl.innerHTML = ''; + if (!isAnnotationPage && isReviewed) { + const fmtEpoch = ++_formatEpoch; + fetch(`/api/v1/export/episode-format/${episodeId}`) + .then(r => r.json()) + .then(d => { + if (fmtEpoch !== _formatEpoch) return; // 已切换批次,丢弃过期响应 + const el = document.getElementById('episode-detail-format'); + if (!el) return; + const label = escHtml(d.label || 'Raw'); + const badge = d.available + ? '' + label + '' + : '' + label + ''; + el.innerHTML = '
' + + t('export') + ': ' + badge + + (d.available ? ' · ' : '') + + '
'; + }) + .catch(() => {}); + } + } + } + +// Re-export:只重建导出,不重跑检测(复用最新 run 的检测产物 + +// 当前工作流的导出配置/连线)。Review 详情 Export 徽标旁按钮。 +// (挂 window:inline onclick 需要全局可见) +window.reExportEpisode = async function () { + if (!currentEpisodeId) return; + const btn = document.getElementById('btn-re-export'); + if (!btn || btn.dataset.busy === '1') return; + btn.dataset.busy = '1'; + btn.textContent = t('re_exporting') + '…'; + let finalStatus = 'done'; + try { + const res = await fetch(`/api/v1/export/re-export/${currentEpisodeId}`, { method: 'POST' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.status); + for (let i = 0; i < 300; i++) { + await new Promise(r => setTimeout(r, 1000)); + let st = { status: 'running' }; + try { + st = await (await fetch(`/api/v1/export/re-export/${currentEpisodeId}/status`)).json(); + } catch (_) { /* keep polling */ } + if (st.status === 'done' || st.status === 'failed' || st.status === 'interrupted') { + finalStatus = st.status; + if (st.status !== 'done') { + alert(t('re_export_failed') + ': ' + (st.detail || st.status)); + } else { + alert(t('re_export_done') + ': ' + (st.detail || '')); + } + break; + } + } + } catch (err) { + finalStatus = 'failed'; + alert(t('re_export_failed') + ': ' + (err.message || err)); + } + btn.dataset.busy = ''; + btn.textContent = t('re_export'); + if (finalStatus === 'done') { + // 刷新徽标(格式可能已变) + const fmtEpoch = ++_formatEpoch; + try { + const d = await (await fetch(`/api/v1/export/episode-format/${currentEpisodeId}`)).json(); + if (fmtEpoch !== _formatEpoch || !currentEpisodeId) return; + const el = document.getElementById('episode-detail-format'); + if (!el) return; + const label = escHtml(d.label || 'Raw'); + const badge = d.available + ? '' + label + '' + : '' + label + ''; + el.innerHTML = '
' + + t('export') + ': ' + badge + + (d.available ? ' · ' : '') + + '
'; + } catch (_) { /* 刷新失败下次进入详情自动重取 */ } + } +} + + // Update frame info header + const frameHeader = document.getElementById('frame-info-header'); + const totalFramesEl = document.getElementById('detail-total-frames'); + if (frameHeader) frameHeader.classList.remove('hidden'); + if (totalFramesEl) totalFramesEl.textContent = ep.frame_count || 0; + + // Show frame controls + if (typeof showAnnotationUI === 'function') showAnnotationUI(); + + // Annotations are loaded by loadEpisodeVideo after frame data is ready + // (avoid race: loadAnnotations needs episodeTotalFrames from loadFrameData) +} + + +// ── Review ────────────────────────────────────────── + +async function markReviewed(episodeId) { + try { + const res = await fetch(`/api/v1/episode/${episodeId}/review`, { method: 'POST' }); + if (res.ok) { + backToList(); + removeEpisodeFromLocalList(episodeId); + scheduleSilentEpisodeRefresh(); + } + } catch (err) { + alert('Operation failed: ' + err.message); + } +} + + +async function unreviewEpisode(episodeId) { + try { + const res = await fetch(`/api/v1/episode/${episodeId}/unreview`, { method: 'POST' }); + if (res.ok) { + backToList(); + removeEpisodeFromLocalList(episodeId); + scheduleSilentEpisodeRefresh(); + } + } catch (err) { + alert('Operation failed: ' + err.message); + } +} + + +async function retryEpisode(episodeId) { + try { + const res = await fetch(`/api/v1/episode/${episodeId}/retry`, { method: 'POST' }); + if (res.ok) { + backToList(); + removeEpisodeFromLocalList(episodeId); + scheduleSilentEpisodeRefresh(); + } + } catch (err) { + alert('Operation failed: ' + err.message); + } +} + + +// ── Reprocess(主动重新处理)────────────────────────── + +async function reprocessEpisode(episodeId) { + if (!confirm(t('confirm_reprocess'))) return; + // The worker will replace the canonical parquet. Do not reuse the old + // full depth/3D browser buffers when this episode is opened again. + if (typeof window.invalidateEpisodePlaybackCache === 'function') { + window.invalidateEpisodePlaybackCache(episodeId); + } + // Clear the old detail workspace before waiting for the queue response. + // Reprocessing is asynchronous; the old media must not remain active + // while the user navigates to another review section. + backToList(); + // Mark the local snapshot before the POST resolves. This closes the small + // window in which a fast click could reopen the old completed artifact. + updateEpisodeInLocalList(episodeId, { _uiWorkflowState: 'queued' }); + try { + const res = await fetch(`/api/v1/episode/${episodeId}/reprocess`, { method: 'POST' }); + if (res.ok) { + scheduleSilentEpisodeRefresh(); + } else { + updateEpisodeInLocalList(episodeId, { _uiWorkflowState: null }); + const err = await res.json(); + alert(t('reprocess_failed') + (err.detail || res.status)); + } + } catch (err) { + updateEpisodeInLocalList(episodeId, { _uiWorkflowState: null }); + alert(t('reprocess_failed') + err.message); + } +} + + +// ── Delete ────────────────────────────────────────── + +async function deleteEpisode(episodeId) { + if (!confirm(t('confirm_trash'))) return; + + try { + const res = await fetch(`/api/v1/episode/${episodeId}/delete`, { method: 'POST' }); + if (res.ok) { + updateTrashBadge(); // 垃圾桶徽标立即 +1,无需刷新页面 + backToList(); // instant feedback — close detail, show list + if (currentEpisodeId === episodeId) { + document.getElementById('video-grid').innerHTML = + '
' + t('deleted_msg') + '
'; + // Clear annotations + annotations = []; + if (typeof clearAnnotationOverlay === 'function') clearAnnotationOverlay(); + } + removeEpisodeFromLocalList(episodeId); + scheduleSilentEpisodeRefresh(); + } + } catch (err) { + alert(t('op_failed') + ': ' + err.message); + } +} + + +/** 从失败的响应里取出可读的失败原因。 + * + * FastAPI 的 ``HTTPException`` 把原因放在 ``detail`` 里(如"Episode is not in + * trash"、"Episode delete verification failed: [...]")。拿不到就退回状态码, + * **绝不能返回空** —— 静默的失败比报错更难查。 */ +async function apiErrorDetail(res) { + try { + const body = await res.json(); + if (body && body.detail) return String(body.detail); + } catch (e) { /* 非 JSON 响应 */ } + return `HTTP ${res.status}`; +} + +async function restoreEpisode(episodeId) { + try { + const res = await fetch(`/api/v1/episode/${episodeId}/restore`, { method: 'POST' }); + if (res.ok) { + updateTrashBadge(); // 恢复后徽标立即 -1 + loadTrashList(); + if (typeof refreshReviewTree === 'function') refreshReviewTree(); + } else { + // ★ 非 2xx 时必须发声。此前只有 if(res.ok) 一个分支 —— 后端返回 + // 409/500 时前端什么都不做,用户看到的是"点了没反应",也无从查起。 + alert(t('restore_failed') + ': ' + await apiErrorDetail(res)); + } + } catch (err) { + alert(t('restore_failed') + ': ' + err.message); + } +} + + +async function permanentDeleteEpisode(episodeId) { + if (!confirm(t('confirm_permanent'))) return; + try { + const res = await fetch(`/api/v1/episode/${episodeId}/permanent`, { method: 'DELETE' }); + if (res.ok) { + updateTrashBadge(); + loadTrashList(); + if (typeof refreshReviewTree === 'function') refreshReviewTree(); + } else { + alert(t('delete_failed') + ': ' + await apiErrorDetail(res)); + } + } catch (err) { + alert(t('delete_failed') + ': ' + err.message); + } +} + + +async function purgeTrash() { + if (!confirm(t('confirm_purge'))) return; + try { + const res = await fetch('/api/v1/episodes/purge-trash', { method: 'POST' }); + if (res.ok) { + updateTrashBadge(); + loadTrashList(); + if (typeof refreshReviewTree === 'function') refreshReviewTree(); + } else { + alert(t('purge_failed') + ': ' + await apiErrorDetail(res)); + } + } catch (err) { + alert(t('purge_failed') + ': ' + err.message); + } +} + + +// ── Export ──────────────────────────────────────────── + +async function downloadEpisode(episodeId) { + // A single episode already has its workflow-published export product. + // That endpoint returns the version selected in the workflow export node. + window.location.href = `/api/v1/export/download-episode/${episodeId}`; +} + + +// ── Init ──────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + // Apply data-i18n translations + document.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.dataset.i18n); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + el.placeholder = t(el.dataset.i18nPlaceholder); + }); + // Init nav labels + const navReview = document.getElementById('nav-review'); + const navTrash = document.getElementById('nav-trash'); + if (navReview) navReview.textContent = t('video_review'); + if (navTrash) navTrash.textContent = t('trash'); + + if (document.getElementById('episode-list-inline')) { + initStatusFromURL(); + installReviewStatusNavigation(); + loadEpisodes(); + setInterval(() => { loadEpisodes({ silent: true }); updateTrashBadge(); }, 15000); + updateTrashBadge(); + // 两个批量按钮的提示:说清一个是重建、一个是原样打包。 + const exportBtn = document.getElementById('batch-export-btn'); + if (exportBtn) exportBtn.title = t('batch_export_hint'); + const zipBtn = document.getElementById('batch-zip-btn'); + if (zipBtn) zipBtn.title = t('batch_zip_hint'); + } + if (document.getElementById('trash-list')) { + loadTrashList(); + } +}); + + +async function loadTrashList() { + const listEl = document.getElementById('trash-list'); + if (!listEl) return; + + listEl.innerHTML = `
${t('loading')}
`; + + try { + const res = await fetch('/api/v1/episodes?status=deleted&limit=200'); + const data = await res.json(); + const episodes = data.episodes || []; + + if (episodes.length === 0) { + listEl.innerHTML = `
${t('trash_empty')}
`; + } else { + const now = new Date(); + listEl.innerHTML = episodes.map(ep => { + const delTime = new Date(ep.deleted_at); + const daysLeft = Math.max(0, Math.ceil(7 - (now - delTime) / (1000 * 60 * 60 * 24))); + const time = new Date(ep.created_at).toLocaleString('zh-CN'); + return ` +
+
+
${ep.task_description || ep.name || 'unknown'}
+
+ ${ep.fps}FPS · ${ep.frame_count}${t('frame')} · ${(ep.camera_names||[]).join(', ') || t('not_found')} +
+
${time}
+
+
${t('days_left')} ${daysLeft} ${t('day_unit')}
+
+ + +
+
`; + }).join(''); + } + updateTrashBadge(); + } catch (err) { + listEl.innerHTML = `
${t('load_failed')}
`; + } +} + + +// updateTrashBadge 由 base.html 全局提供(所有页面生效),删除/恢复/清空后 +// 各操作函数调用它立即刷新徽标,无需刷新页面。 diff --git a/processor/web/static/js/dashboard.js b/processor/web/static/js/dashboard.js index a2458f6ab1e7ffad5a12b0b3c0789c34a7748323..ec3b8cbee5097690e898b72901a03f37193e1862 100644 --- a/processor/web/static/js/dashboard.js +++ b/processor/web/static/js/dashboard.js @@ -28,13 +28,13 @@ function timeAgo(isoStr) { function statusBadge(status) { var map = { - 'completed': { cls: 'bg-yellow-900/50 text-yellow-400', label: 'Reviewing' }, - 'to_review': { cls: 'bg-yellow-900/50 text-yellow-400', label: 'Reviewing' }, - 'reviewed': { cls: 'bg-green-900/50 text-green-400', label: 'Approved' }, - 'approved': { cls: 'bg-green-900/50 text-green-400', label: 'Approved' }, - 'processing': { cls: 'bg-blue-900/50 text-blue-400', label: 'Processing' }, - 'received': { cls: 'bg-indigo-900/50 text-indigo-400', label: 'Received' }, - 'failed': { cls: 'bg-red-900/50 text-red-400', label: 'Failed' }, + 'completed': { cls: 'bg-yellow-900/50 text-yellow-400', label: t('stat_reviewing') }, + 'to_review': { cls: 'bg-yellow-900/50 text-yellow-400', label: t('stat_reviewing') }, + 'reviewed': { cls: 'bg-green-900/50 text-green-400', label: t('stat_approved') }, + 'approved': { cls: 'bg-green-900/50 text-green-400', label: t('stat_approved') }, + 'processing': { cls: 'bg-blue-900/50 text-blue-400', label: t('stat_processing') }, + 'received': { cls: 'bg-indigo-900/50 text-indigo-400', label: t('stat_received') }, + 'failed': { cls: 'bg-red-900/50 text-red-400', label: t('stat_failed') }, }; var m = map[status] || { cls: 'bg-gray-800 text-gray-400', label: status || 'unknown' }; return '' + m.label + ''; @@ -180,9 +180,9 @@ function renderRecent(episodes) { el.innerHTML = episodes.map(function(ep) { var cleanIcon = ''; if (ep.cleaning_passed === false) { - cleanIcon = ' '; + cleanIcon = ' '; } else if (ep.cleaning_passed === true) { - cleanIcon = ' '; + cleanIcon = ' '; } return '' + '' + (ep.task_name || '').slice(0, 18) + cleanIcon + '' + @@ -203,9 +203,9 @@ function renderDonut(reviewing, approved, failed) { var total = reviewing + approved + failed || 1; var segments = [ - { value: reviewing, color: '#eab308', label: 'To Review' }, // yellow - { value: approved, color: '#22c55e', label: 'Approved' }, // green - { value: failed, color: '#ef4444', label: 'Failed' }, // red + { value: reviewing, color: '#eab308', label: t('stat_to_review') }, // yellow + { value: approved, color: '#22c55e', label: t('stat_approved') }, // green + { value: failed, color: '#ef4444', label: t('stat_failed') }, // red ]; var radius = 60; @@ -240,7 +240,7 @@ function renderDonut(reviewing, approved, failed) { html += '' + fmtNum(total) + ''; html += 'Total'; + 'class="fill-gray-500" style="font-size:11px">' + t('donut_total') + ''; html += ''; diff --git a/processor/web/static/js/i18n-data.js b/processor/web/static/js/i18n-data.js new file mode 100644 index 0000000000000000000000000000000000000000..56286ce64072cfc60a7da8c7d0cba1bd8acbc16f --- /dev/null +++ b/processor/web/static/js/i18n-data.js @@ -0,0 +1,843 @@ +// 中英词典 —— **唯一的真源**。 +// +// 为什么单独一个文件、而不是写在 i18n.js 里: +// 服务端渲染需要它(app/i18n.py 读这个文件),而服务端是 Python。 +// 写在 .js 里的话,Python 要么重复维护一份、要么去解析 JS 源码。 +// 这里用**合法 JSON** 作为载荷,外面只包一行赋值 —— Python 剥掉包装就能 +// json.loads,浏览器当一个普通脚本同步加载(带 ?v= 缓存,不会每页重下)。 +// +// 改词条只改这个文件。别在 i18n.js 里再加字面量。 +window.__I18N__ = { + "zh": { + "app_title": "processor", + "language": "语言", + "video_review": "视频审核", + "trash": "回收站", + "all_status": "全部状态", + "reviewing": "审核中", + "reviewed": "已审核", + "all_tasks": "全部任务", + "search": "搜索...", + "loading": "加载中...", + "processing": "处理中...", + "ai_annotating": "AI 标注处理中...", + "ai_annotating_hint": "AI 标注完成后自动载入本批次数据", + "no_data": "暂无数据", + "load_failed": "加载失败", + "select_episode": "选择一个视频开始审核 →", + "no_video": "此 Episode 没有视频数据", + "cameras": "相机", + "play_all": "▶ 全部播放", + "pause_all": "⏸ 全部暂停", + "play_btn": "播放", + "pause_btn": "暂停", + "layout_row": "并排", + "layout_grid": "网格", + "layout_column": "上下", + "no_description": "无任务描述", + "frame": "帧", + "download": "下载", + "export": "导出", + "export_hint": "按工作流导出格式下载最终数据集", + "re_export": "重新导出", + "re_exporting": "重新导出中", + "re_export_done": "重新导出完成", + "re_export_failed": "重新导出失败", + "delete": "删除", + "approve": "通过", + "deleted_msg": "已移入回收站", + "select_hint": "选择 Episode 后显示详情", + "trash_empty": "回收站为空", + "trash_title": "回收站", + "purge_all": "全部清空", + "restore": "恢复", + "permanent_delete": "永久删除", + "days_left": "剩余", + "day_unit": "天", + "confirm_trash": "确定将此视频移入回收站吗?\n7 天后自动清理。", + "confirm_permanent": "确定永久删除吗?此操作不可恢复!", + "confirm_purge": "确定清空回收站吗?所有内容将被永久删除!", + "op_failed": "操作失败", + "restore_failed": "恢复失败", + "delete_failed": "删除失败", + "purge_failed": "清空失败", + "export_failed": "导出失败", + "no_reviewed": "没有已审核的 Episode 可导出", + "export_created": "导出任务已创建,完成后自动下载", + "left_hand": "左手", + "right_hand": "右手", + "single_mono": "单目视频", + "glove_sensor": "手套传感器", + "depth_image": "深度图", + "aux_video": "辅助视频", + "skeleton_suffix": "骨骼", + "glove_suffix": "手套", + "depth_suffix": "深度", + "stereo_pair": "双目并排", + "3d_skeleton": "3D 骨骼", + "stereo_render": "3D 拼接", + "hand3d_no_data": "该帧无 3D 骨骼数据", + "hand3d_hand0": "手 0", + "hand3d_hand1": "手 1", + "hand3d_world": "3D 手部世界坐标", + "heatmap_toggle": "深度热力图", + "synced_ok": "同步正常", + "drag_here": "拖拽素材到这里", + "remove_source": "移除素材", + "video_suffix": "视频", + "hand_pressure": "手部压力", + "media_assets": "素材", + "drag_to_canvas": "拖拽素材到中间视频区域", + "raw_vs_processed": "原始视频 / 处理结果", + "refresh": "刷新", + "not_found": "未找到", + "deleted": "已删除", + "port_label": "Port", + "annotation": "动作标注", + "annotation_readonly": "只读", + "annotations": "标注段", + "new_annotation": "新增标注", + "edit_annotation": "编辑标注", + "delete_annotation": "删除标注", + "label_name": "标注名称", + "start_frame": "起始帧", + "end_frame": "结束帧", + "set_start_frame": "设起始帧", + "set_end_frame": "设结束帧", + "keyframe": "关键帧", + "add_keyframe": "添加关键帧", + "frames_covered": "帧已标注", + "per_frame_label": "每帧标注", + "no_annotations": "暂无标注,点击\"新增\"创建第一个标注段", + "confirm_delete_anno": "确定删除此标注段吗?", + "save_annotation": "保存标注", + "save_changes": "保存修改", + "slice_conflict": "此切片已在另一台电脑修改,已加载最新版本。请确认后再次保存。", + "current_frame": "当前帧", + "anno_color": "颜色", + "anno_frame_range": "帧范围", + "anno_duration": "时长", + "anno_keyframe_event": "关键帧事件", + "seconds_abbr": "s", + "select_all": "全选", + "batch_download": "批量下载", + "batch_export_dataset": "导出数据集 (慢)", + "batch_zip_download": "打包下载 (快)", + "batch_zip_hint": "从源数据集原样裁出所选批次 —— 产出仍是单个 LeRobot 结构(data/ + meta/ + videos/),不重建、秒级返回。只想把这批数据拿走就用这个。", + "batch_export_hint": "把所选批次合并成**一个**可训练数据集(需要整库重建:重编码视频 + 重算统计,约 30 秒/集)。要拿去训练才用这个。", + "batch_selected_summary": "已选 %s 集 · %f 帧", + "minutes_short": "分", + "select_project_all": "全选本项目(含折叠中的批次)", + "unreview": "撤销", + "retry_review": "放入审核", + "reprocess": "重新处理", + "confirm_reprocess": "重新运行该批次绑定的工作流?新产物将替换旧处理结果。", + "reprocess_failed": "重新处理失败: ", + "nav_home": "首页", + "nav_tasks": "项目", + "nav_review": "视频审核", + "nav_trash": "回收站", + "nav_workflow": "工作流", + "nav_users": "用户", + "nav_annotation": "标注", + "nav_model": "模型", + "train_tab_checkpoints": "模型输出", + "train_tab_params": "训练参数", + "train_label_model": "模型", + "train_label_location": "位置", + "train_files_unit": "个文件", + "train_group_train": "训练", + "train_group_policy": "策略 Policy", + "train_group_optimizer": "优化器", + "train_group_dataset": "数据集", + "train_step_badge": "步骤", + "train_file_details": "文件明细", + "train_col_name": "文件名", + "train_col_size": "大小", + "train_col_mtime": "修改时间", + "train_btn_download": "下载", + "train_btn_delete": "删除", + "train_btn_infer": "推理", + "train_btn_deploy": "离线部署", + "train_btn_hint_delete": "删除 checkpoint 是破坏性操作,暂不开放", + "train_btn_hint_download_row": "展开文件明细 —— 那里每行单独下载", + "train_btn_hint_infer_row": "切到「模型推理」页签并选中本检查点", + "train_btn_hint_deploy": "属于部署模块,本期未做", + "train_checkpoints_empty": "这个训练目录下没有 checkpoint。", + "train_params_source": "来源:", + "train_params_empty": "这个 checkpoint 没有 train_config.json,读不到训练参数。", + "train_page_runs": "模型训练", + "train_page_new": "新建训练", + "train_page_inference": "模型推理", + "train_new_live": "实时", + "train_new_pending": "待接入", + "train_new_model_tested": "已实测", + "train_new_model_act_desc": "基于 Transformer 的动作分块模仿学习模型", + "train_new_frames": "帧", + "train_new_reset": "恢复实测默认值", + "train_sec_dataset": "① 训练数据集", + "train_sec_model": "② 选择模型", + "train_sec_location": "③ 训练位置", + "train_sec_params": "④ 训练参数", + "train_sec_resume": "⑤ 从已有训练继续(可选)", + "train_loc_local": "私有算力", + "train_loc_local_desc": "本机显卡直连", + "train_gpu_unavailable": "读不到 GPU", + "train_gpu_unavailable_hint": "训练前请先修驱动 —— 驱动不可用时训练起不来。", + "train_gpu_none": "nvidia-smi 没有报告任何 GPU。", + "train_gpu_busy": "占用中", + "train_gpu_idle": "空闲", + "train_gpu_mem": "显存", + "train_gpu_util": "GPU 利用率", + "train_gpu_temp": "温度", + "train_gpu_include": "参与训练", + "train_framework": "训练框架", + "train_framework_hint": "ACT 在 LeRobot 里只有一种实现,这里没有可选项。", + "train_help_job_name": "这次训练的名字,会成为 checkpoint 的文件夹名 —— 取短一点、别重名。", + "train_help_batch": "较大的批次可以提高训练稳定性,但需要更多显存;较小的批次训练更快但可能不稳定。建议范围 1-32。", + "train_help_steps": "更多步数通常能提高模型性能,但可能导致过拟合。建议从 10000 开始,根据验证结果调整。", + "train_help_seed": "设置固定种子可以复现相同的训练结果,便于调试和比较。建议用 1000、42 等固定值。", + "train_help_workers": "更多工作进程可以加速数据加载,但会占用更多内存。建议设置为 CPU 核心数的 1/2 到 1 倍。", + "train_help_eval_freq": "设置多少步进行一次模型评估,建议设为总步数的 10%。", + "train_help_log_freq": "设置多少步刷新一次进度,建议 10 到 100 步。", + "train_help_save_ckpt": "开启后会在训练过程中保存模型检查点,便于恢复训练或部署模型。", + "train_help_save_freq": "设置多少步保存一次模型检查点,建议设为总步数的 30%。", + "train_ready_title": "已达采集上限,可以导出了", + "train_ready_go": "去导出", + "train_dataset_none": "还没有导出的数据集。", + "train_dataset_goto_export": "去导出已通过的集", + "train_episodes": "集", + "train_resume_none": "— 全新训练 —", + "train_resume_latest": "— 最新 —", + "train_resume_task": "源训练任务", + "train_resume_step": "检查点", + "train_no_output": "这次训练没有可展示的产物。", + "train_no_root": "本机没有训练产物目录", + "train_no_runs": "该目录下没有训练产物", + "train_load_failed": "读取训练产物失败", + "menu_profile": "个人资料", + "menu_change_password": "修改密码", + "menu_logout": "退出登录", + "users_title": "用户管理", + "users_subtitle": "管理账号、角色与账号生命周期。", + "users_new": "新建用户", + "users_total": "用户总数", + "users_active": "活跃用户", + "users_disabled_expired": "已禁用 / 已过期", + "status_active": "正常", + "status_expired": "已过期", + "status_disabled": "已禁用", + "users_disable_warning": "会临时禁止登录,但保留账号与数据,之后可再启用。", + "users_all_roles": "全部角色", + "users_all_status": "全部状态", + "role_admin": "管理员", + "role_engineer": "工程师", + "role_reviewer": "审核员", + "col_user": "用户", + "col_role": "角色", + "col_status": "状态", + "col_last_login": "最近登录", + "col_expires": "到期", + "col_actions": "操作", + "users_extend": "延期", + "users_extend_hint": "修改到期时间以重新启用该账号", + "users_disable": "禁用", + "users_disable_hint": "临时禁止登录,之后可再启用", + "users_enable": "启用", + "users_enable_hint": "允许该用户重新登录", + "users_edit": "编辑", + "users_edit_hint": "编辑账号", + "users_edit_user": "编辑用户", + "users_save_changes": "保存修改", + "users_create": "创建用户", + "users_delete": "删除", + "users_delete_hint": "永久删除该账号", + "users_delete_warning": "将永久删除该账号。", + "users_confirm_delete": "永久删除 %s?如果以后可能还要用这个账号,禁用更稳妥。", + "users_role_perms": "角色权限", + "users_perm_admin": "用户、项目、工作流、处理、审核与系统设置", + "users_perm_engineer": "项目、工作流、处理与审核", + "users_perm_reviewer": "查看数据、视频审核与标注审核", + "field_username": "用户名", + "field_email": "邮箱", + "field_password": "密码", + "field_expires_at": "到期时间", + "users_pw_hint": "(至少 6 个字符)", + "users_pw_keep": "(留空则保持原密码)", + "users_optional": "(可选)", + "users_no_found": "未找到用户", + "users_no_email": "无邮箱", + "users_never": "从未", + "users_count": "%s 个用户", + "preview_options": "预览选项", + "video_sources": "视频源", + "all": "全部", + "hand_skeleton": "手部骨骼", + "hand_trail": "手部轨迹", + "frame_label": "帧:", + "play": "播放", + "select": "选择", + "name_1_to_n": "命名 1→N", + "name_n_to_1": "命名 N→1", + "sort_label": "排序:", + "n_selected": "已选 %s", + "undo": "撤销", + "new_export": "新建导出", + "dataset_name": "数据集名称", + "train_ratio": "训练集占比", + "start_export": "开始导出", + "dataset": "数据集", + "progress": "进度", + "created": "创建时间", + "task_active": "进行中", + "task_paused": "已暂停", + "ai_status_segmenting": "切段中…", + "ai_status_analyzing": "VLM 分析中…", + "ai_status_writing": "写入…", + "ai_status_exporting": "写入数据集…", + "force_unit_mn": "力 mN", + "force_field_title": "接触力场(固定全幅,不可缩放)", + "slice_boundary_suspect": "边界可疑或标签未命中词表", + "anno_ai_mode_title": "AI 自动切段并提案标注(模式由工作流 AI Annotation 卡片配置决定)", + "overview_title": "概览", + "load_failed_stats": "加载统计数据失败", + "retry": "重试", + "stat_reviewing": "待审核", + "stat_approved": "已通过", + "stat_failed": "异常", + "stat_total": "全部", + "stat_to_review": "待审核", + "stat_distribution": "状态分布", + "daily_uploads": "每日上传", + "last_30_days": "(近 30 天)", + "donut_total": "总计", + "stat_processing": "处理中", + "stat_received": "已接收", + "cleaning_passed": "质检通过", + "cleaning_failed": "质检未通过", + "stat_anomaly": "异常", + "recent_episodes": "最近 Episode", + "view_all": "查看全部", + "pipeline_status": "Pipeline 状态", + "new_task": "新建任务", + "edit_task": "编辑任务", + "task_name": "任务名称", + "task_name_hint": "请使用字母/数字/下划线/中文,需与上传文件前缀匹配", + "task_description": "描述", + "claimer": "认领人", + "target_episodes": "目标条数", + "target_duration": "目标时长(秒)", + "fps": "帧率", + "save": "保存", + "cancel": "取消", + "delete_task": "删除任务", + "confirm_delete_task": "确定删除此任务吗?(已有采集数据将保留)", + "episodes": "Episodes", + "frames": "帧", + "no_frame_target": "无帧数目标", + "devices": "设备", + "online": "在线", + "offline": "离线", + "unknown": "未知", + "last_seen": "最近活跃", + "device_empty": "暂无设备接入", + "expand_episodes": "查看 Episodes", + "load_more": "加载更多", + "no_tasks": "暂无任务,点击\"新建任务\"创建第一个采集任务", + "projects_title": "项目", + "search_projects": "搜索项目...", + "new_project": "新建项目", + "edit_project": "编辑项目", + "project_name": "项目名称", + "bind_workflow": "绑定工作流", + "workflow_placeholder": "— 选择工作流 —", + "device_type": "设备类型", + "status": "状态", + "no_projects": "暂无项目,点击右上角「新建项目」创建", + "tasks_count": "任务", + "videos_count": "视频", + "ep_count": "条", + "upload_new": "新批次", + "upload_reupload": "重传", + "upload_total": "总上传", + "upload_unclassified": "历史未分类", + "target_unlimited": "不限", + "no_tasks_in_project": "该项目下暂无任务(上传数据后自动归类)", + "delete_project": "删除", + "confirm_delete_project": "确定删除项目「", + "confirm_delete_project_suffix": "」?项目下的任务/视频记录不会被删除。", + "project_saved": "保存成功", + "project_save_failed": "保存失败", + "project_delete_failed": "删除失败", + "project_load_failed": "加载失败", + "unbound_workflow": "未绑定工作流", + "workflow_edit": "编辑", + "workflow_selected": "已选 %s 个工作流", + "project_need_workflow": "项目设为 active 前必须先绑定至少一个工作流", + "new_workflow_panel": "新建工作流", + "new_workflow_prompt": "新工作流名称:", + "workflow_create_failed": "创建工作流失败", + "workflow_empty": "空", + "template_badge": "模板", + "project_saved_go_build": "项目已保存。是否前往工作流画布搭建/查看工作流?", + "status_purged": "已彻底删除", + "observed_inputs": "输入", + "exceptions_badge": "异常", + "exceptions": "异常", + "exception_kind_mismatch": "输入不匹配", + "exception_kind_missing": "设备数据缺失", + "input_missing_title": "工作流设备数据不完整", + "input_missing_detail": "以下设备缺失,本批次仅处理已上传设备:", + "exception_kind_failed": "运行失败", + "exception_delete": "删除异常", + "exception_delete_failed": "删除异常失败", + "anno_timeline": "标记时间轴", + "slice_marker": "切片标记", + "new_slice": "新建切片", + "no_slices": "暂无切片标记", + "no_slices_hint": "拖动时间轴后创建一个标记区间", + "current_slice": "当前标记", + "enter_slice_name": "请输入标记名称", + "add_slice": "添加切片", + "ai_annotate": "AI 标注", + "unnamed_slice": "未命名切片", + "slice_save_failed": "保存失败: ", + "slice_delete_failed": "删除失败: ", + "inf_sec_model": "① 选择模型", + "inf_sec_data": "② 测试数据", + "inf_sec_run": "③ 运行", + "inf_sec_result": "④ 结果", + "inf_label_run": "训练任务", + "inf_label_step": "检查点", + "inf_label_dataset": "测试集", + "inf_live": "实时", + "inf_pending": "待接入", + "inf_no_params": "这个检查点没有 train_config.json,读不到训练参数。", + "inf_params_from": "来源", + "inf_no_models": "还没有训练好的模型", + "inf_dataset_none": "— 选择测试集 —", + "inf_datasets_available": "个数据集可选", + "inf_no_datasets": "还没有导出的数据集 —— 先去审核页导出一份。", + "inf_run_btn": "运行推理", + "inf_run_disabled": "后端执行尚未接入", + "inf_run_note": "后端执行还没接 —— 这个按钮是**故意禁用**的。", + "inf_run_hint": "接上之后:用所选检查点在测试集上跑一遍,给出方向余弦、幅度比、夹爪 MAE 与误差曲线(eval_gui.py 那套指标)。", + "inf_no_result": "还没有评估结果。" + }, + "en": { + "app_title": "processor", + "language": "Language", + "video_review": "Video Review", + "trash": "Trash", + "all_status": "All Status", + "reviewing": "Reviewing", + "reviewed": "Reviewed", + "all_tasks": "All Tasks", + "search": "Search...", + "loading": "Loading...", + "processing": "Processing...", + "ai_annotating": "AI annotation running...", + "ai_annotating_hint": "This batch becomes viewable once AI annotation finishes", + "no_data": "No data", + "load_failed": "Load failed", + "select_episode": "Select an episode to review →", + "no_video": "No video data", + "cameras": "Cameras", + "play_all": "▶ Play All", + "pause_all": "⏸ Pause All", + "play_btn": "Play", + "pause_btn": "Pause", + "layout_row": "Row", + "layout_grid": "Grid", + "layout_column": "Stack", + "no_description": "No description", + "frame": "frames", + "download": "Download", + "export": "Export", + "export_hint": "Download final dataset in workflow export format", + "re_export": "Re-export", + "re_exporting": "Re-exporting…", + "re_export_done": "Re-export done", + "re_export_failed": "Re-export failed", + "delete": "Delete", + "approve": "Approve", + "deleted_msg": "Moved to trash", + "select_hint": "Select an episode to view details", + "trash_empty": "Trash is empty", + "trash_title": "Trash", + "purge_all": "Purge All", + "restore": "Restore", + "permanent_delete": "Delete", + "days_left": "Remaining", + "day_unit": "days", + "confirm_trash": "Move to trash?\nAuto-cleaned after 7 days.", + "confirm_permanent": "Permanently delete? This cannot be undone!", + "confirm_purge": "Purge all trash? All items will be permanently deleted!", + "op_failed": "Operation failed", + "restore_failed": "Restore failed", + "delete_failed": "Delete failed", + "purge_failed": "Purge failed", + "export_failed": "Export failed", + "no_reviewed": "No reviewed episodes to export", + "export_created": "Export job created; it will download when ready", + "left_hand": "Left Hand", + "right_hand": "Right Hand", + "single_mono": "Single Camera", + "glove_sensor": "Glove Sensors", + "depth_image": "Depth Images", + "aux_video": "Aux Video", + "skeleton_suffix": "Skeleton", + "glove_suffix": "Glove", + "depth_suffix": "Depth", + "stereo_pair": "Stereo Pair", + "3d_skeleton": "3D Skeleton", + "stereo_render": "3D Side-by-Side", + "hand3d_no_data": "No 3D skeleton data for this frame", + "hand3d_hand0": "Hand 0", + "hand3d_hand1": "Hand 1", + "hand3d_world": "3D Hand World", + "heatmap_toggle": "Depth Heatmap", + "synced_ok": "Synced", + "drag_here": "Drag assets here", + "remove_source": "Remove", + "video_suffix": "Video", + "hand_pressure": "Hand Pressure", + "media_assets": "Assets", + "drag_to_canvas": "Drag assets onto the video area", + "raw_vs_processed": "Raw video / Processing results", + "refresh": "Refresh", + "not_found": "Not found", + "deleted": "Deleted", + "port_label": "Port", + "annotation": "Annotation", + "annotation_readonly": "Read only", + "annotations": "Annotations", + "new_annotation": "New Annotation", + "edit_annotation": "Edit Annotation", + "delete_annotation": "Delete Annotation", + "label_name": "Label Name", + "start_frame": "Start Frame", + "end_frame": "End Frame", + "set_start_frame": "Set Start", + "set_end_frame": "Set End", + "keyframe": "Keyframe", + "add_keyframe": "Add Keyframe", + "frames_covered": "frames labeled", + "per_frame_label": "per-frame label", + "no_annotations": "No annotations. Click \"+\" to create one.", + "confirm_delete_anno": "Delete this annotation segment?", + "save_annotation": "Save Annotation", + "save_changes": "Save Changes", + "slice_conflict": "This slice was changed on another computer. The latest version has been loaded.", + "current_frame": "Current Frame", + "anno_color": "Color", + "anno_frame_range": "Frame Range", + "anno_duration": "Duration", + "anno_keyframe_event": "Keyframe Event", + "seconds_abbr": "s", + "select_all": "Select All", + "batch_download": "Batch Download", + "batch_export_dataset": "Export dataset (slow)", + "batch_zip_download": "Zip download (fast)", + "batch_zip_hint": "Carves the selected episodes out of the source dataset as-is — still a single LeRobot structure (data/ + meta/ + videos/), no rebuild, returns in seconds. Use this to just take the data away.", + "batch_export_hint": "Merges the selected episodes into ONE trainable dataset (full rebuild: re-encode videos + recompute stats, ~30s/episode). Use this only for training.", + "batch_selected_summary": "%s episodes · %f frames", + "minutes_short": "min", + "select_project_all": "Select all in this project (including collapsed)", + "unreview": "Unreview", + "retry_review": "Re-review", + "reprocess": "Reprocess", + "confirm_reprocess": "Re-run the bound workflows for this episode? New output will replace the old processing result.", + "reprocess_failed": "Reprocess failed: ", + "nav_home": "Home", + "nav_tasks": "Projects", + "nav_review": "Video Review", + "nav_trash": "Trash", + "nav_workflow": "Workflow", + "nav_users": "Users", + "nav_annotation": "Annotation", + "nav_model": "Model", + "train_tab_checkpoints": "Model Output", + "train_tab_params": "Training Config", + "train_label_model": "Model", + "train_label_location": "Location", + "train_files_unit": "files", + "train_group_train": "Training", + "train_group_policy": "Policy", + "train_group_optimizer": "Optimizer", + "train_group_dataset": "Dataset", + "train_step_badge": "Step", + "train_file_details": "File details", + "train_col_name": "Name", + "train_col_size": "Size", + "train_col_mtime": "Modified", + "train_btn_download": "Download", + "train_btn_delete": "Delete", + "train_btn_infer": "Inference", + "train_btn_deploy": "Offline Deploy", + "train_btn_hint_delete": "Deleting a checkpoint is destructive; not exposed yet", + "train_btn_hint_download_row": "Expand the file list — each file downloads individually", + "train_btn_hint_infer_row": "Open the Inference tab with this checkpoint selected", + "train_btn_hint_deploy": "Belongs to the Deployment module, not built yet", + "train_checkpoints_empty": "No checkpoints under this run.", + "train_params_source": "Source:", + "train_params_empty": "No train_config.json in this checkpoint; parameters unavailable.", + "train_page_runs": "Model Training", + "train_page_new": "New Training", + "train_page_inference": "Model Inference", + "train_new_live": "live", + "train_new_pending": "not wired", + "train_new_model_tested": "tested", + "train_new_model_act_desc": "Transformer action-chunking imitation learning policy", + "train_new_frames": "frames", + "train_new_reset": "Restore measured defaults", + "train_sec_dataset": "1. Training Dataset", + "train_sec_model": "2. Model", + "train_sec_location": "3. Training Location", + "train_sec_params": "4. Training Parameters", + "train_sec_resume": "5. Continue From a Previous Run (optional)", + "train_loc_local": "Private Compute", + "train_loc_local_desc": "GPUs on this machine", + "train_gpu_unavailable": "Cannot read the GPUs", + "train_gpu_unavailable_hint": "Fix the driver before training — training cannot start without it.", + "train_gpu_none": "nvidia-smi reported no GPUs.", + "train_gpu_busy": "busy", + "train_gpu_idle": "idle", + "train_gpu_mem": "VRAM", + "train_gpu_util": "GPU util", + "train_gpu_temp": "Temp", + "train_gpu_include": "Use for training", + "train_framework": "Training framework", + "train_framework_hint": "ACT ships as a single LeRobot implementation, so there is nothing to choose here.", + "train_help_job_name": "Name of this run. It becomes the checkpoint folder name, so keep it short and unique.", + "train_help_batch": "Larger batches train more stably but need more VRAM; smaller ones are faster but less stable. Suggested 1-32.", + "train_help_steps": "More steps usually improve the policy but risk overfitting. Start at 10000 and adjust from validation.", + "train_help_seed": "A fixed seed reproduces the same run, which makes debugging and comparison possible. 1000 or 42.", + "train_help_workers": "More workers load data faster but use more memory. About 1/2 to 1x the CPU core count.", + "train_help_eval_freq": "How often to run an evaluation, in steps. About 10% of total steps.", + "train_help_log_freq": "How often to refresh progress, in steps. 10-100 works well.", + "train_help_save_ckpt": "Save checkpoints while training so a run can be resumed or deployed.", + "train_help_save_freq": "How often to save a checkpoint, in steps. About 30% of total steps.", + "train_ready_title": "Collection limit reached — ready to export", + "train_ready_go": "Go export", + "train_dataset_none": "No exported dataset yet.", + "train_dataset_goto_export": "Go export approved episodes", + "train_episodes": "episodes", + "train_resume_none": "— Start fresh —", + "train_resume_latest": "— latest —", + "train_resume_task": "Source run", + "train_resume_step": "Checkpoint", + "train_no_output": "This run has no artifacts to show.", + "train_no_root": "No training output directory on this host", + "train_no_runs": "No training output under this directory", + "train_load_failed": "Failed to load training output", + "menu_profile": "Profile", + "menu_change_password": "Change Password", + "menu_logout": "Logout", + "users_title": "User management", + "users_subtitle": "Manage accounts, roles and account lifecycle.", + "users_new": "New user", + "users_total": "Total users", + "users_active": "Active users", + "users_disabled_expired": "Disabled / expired", + "status_active": "Active", + "status_expired": "Expired", + "status_disabled": "Disabled", + "users_disable_warning": "blocks login temporarily and keeps the account/data; it can be enabled later.", + "users_all_roles": "All roles", + "users_all_status": "All status", + "role_admin": "Admin", + "role_engineer": "Engineer", + "role_reviewer": "Reviewer", + "col_user": "User", + "col_role": "Role", + "col_status": "Status", + "col_last_login": "Last login", + "col_expires": "Expires", + "col_actions": "Actions", + "users_extend": "Extend", + "users_extend_hint": "Edit the expiry date to reactivate this account", + "users_disable": "Disable", + "users_disable_hint": "Block login temporarily; can be enabled later", + "users_enable": "Enable", + "users_enable_hint": "Allow this user to log in again", + "users_edit": "Edit", + "users_edit_hint": "Edit account", + "users_edit_user": "Edit user", + "users_save_changes": "Save changes", + "users_create": "Create user", + "users_delete": "Delete", + "users_delete_hint": "Permanently delete this account", + "users_delete_warning": "permanently removes the account.", + "users_confirm_delete": "Permanently delete %s? Disable is safer if you may need the account later.", + "users_role_perms": "Role permissions", + "users_perm_admin": "Users, projects, workflows, processing, review and system settings", + "users_perm_engineer": "Projects, workflows, processing and review", + "users_perm_reviewer": "View data, video review and annotation review", + "field_username": "Username", + "field_email": "Email", + "field_password": "Password", + "field_expires_at": "Expires at", + "users_pw_hint": "(at least 6 characters)", + "users_pw_keep": "(leave blank to keep current password)", + "users_optional": "(optional)", + "users_no_found": "No users found", + "users_no_email": "No email", + "users_never": "Never", + "users_count": "%s users", + "preview_options": "Preview Options", + "video_sources": "Video Sources", + "all": "All", + "hand_skeleton": "Hand Skeleton", + "hand_trail": "Hand Trail", + "frame_label": "Frame:", + "play": "Play", + "select": "Select", + "name_1_to_n": "Name 1→N", + "name_n_to_1": "Name N→1", + "sort_label": "Sort: ", + "n_selected": "%s selected", + "undo": "Undo", + "new_export": "New Export", + "dataset_name": "Dataset Name", + "train_ratio": "Train Ratio", + "start_export": "Start Export", + "dataset": "Dataset", + "progress": "Progress", + "created": "Created", + "task_active": "active", + "task_paused": "paused", + "ai_status_segmenting": "Segmenting…", + "ai_status_analyzing": "Analyzing (VLM)…", + "ai_status_writing": "Writing…", + "ai_status_exporting": "Writing dataset…", + "force_unit_mn": "Force (mN)", + "force_field_title": "Contact force field (fixed full frame, not zoomable)", + "slice_boundary_suspect": "Suspicious boundary or label not in vocabulary", + "anno_ai_mode_title": "AI auto-segments and proposes annotations (mode is configured on the workflow AI Annotation card)", + "overview_title": "Overview", + "load_failed_stats": "Failed to load statistics", + "retry": "Retry", + "stat_reviewing": "Reviewing", + "stat_approved": "Approved", + "stat_failed": "Failed", + "stat_total": "All Episodes", + "stat_to_review": "To Review", + "stat_distribution": "Status Distribution", + "daily_uploads": "Daily Uploads", + "last_30_days": "(last 30 days)", + "donut_total": "Total", + "stat_processing": "Processing", + "stat_received": "Received", + "cleaning_passed": "Cleaning passed", + "cleaning_failed": "Cleaning failed", + "stat_anomaly": "Anomaly", + "recent_episodes": "Recent Episodes", + "view_all": "View All", + "pipeline_status": "Pipeline Status", + "new_task": "New Task", + "edit_task": "Edit Task", + "task_name": "Task Name", + "task_name_hint": "Letters/digits/underscore/Chinese; must match zip filename prefix", + "task_description": "Description", + "claimer": "Claimer", + "target_episodes": "Target Episodes", + "target_duration": "Target Duration (s)", + "fps": "FPS", + "save": "Save", + "cancel": "Cancel", + "delete_task": "Delete Task", + "confirm_delete_task": "Delete this task? (Existing episodes will be kept)", + "episodes": "Episodes", + "frames": "Frames", + "no_frame_target": "No frame target", + "devices": "Devices", + "online": "Online", + "offline": "Offline", + "unknown": "Unknown", + "last_seen": "Last Seen", + "device_empty": "No devices connected", + "expand_episodes": "View Episodes", + "load_more": "Load More", + "no_tasks": "No tasks yet. Click \"New Task\" to create one.", + "projects_title": "Projects", + "search_projects": "Search projects...", + "new_project": "New Project", + "edit_project": "Edit Project", + "project_name": "Project Name", + "bind_workflow": "Workflow", + "workflow_placeholder": "— Select workflow —", + "device_type": "Device Type", + "status": "Status", + "no_projects": "No projects yet. Click \"New Project\" to create one.", + "tasks_count": "tasks", + "videos_count": "videos", + "ep_count": "episodes", + "upload_new": "new batches", + "upload_reupload": "reuploads", + "upload_total": "total uploads", + "upload_unclassified": "legacy unclassified", + "target_unlimited": "unlimited", + "no_tasks_in_project": "No tasks under this project (uploads are grouped automatically).", + "delete_project": "Delete", + "confirm_delete_project": "Delete project \"", + "confirm_delete_project_suffix": "\"? Task/episode records are kept.", + "project_saved": "Saved", + "project_save_failed": "Save failed", + "project_delete_failed": "Delete failed", + "project_load_failed": "Load failed", + "unbound_workflow": "No workflow bound", + "workflow_edit": "Edit", + "workflow_selected": "%s workflows selected", + "project_need_workflow": "Bind at least one workflow before activating this project", + "new_workflow_panel": "+ New Workflow", + "new_workflow_prompt": "Name for the new workflow:", + "workflow_create_failed": "Failed to create workflow", + "workflow_empty": "empty", + "template_badge": "Template", + "project_saved_go_build": "Project saved. Go to Workflow Studio to build/view the workflow?", + "status_purged": "Purged", + "observed_inputs": "Inputs", + "exceptions_badge": "exceptions", + "exceptions": "Exceptions", + "exception_kind_mismatch": "Input mismatch", + "exception_kind_missing": "Missing device data", + "input_missing_title": "Workflow inputs are incomplete", + "input_missing_detail": "The following devices are missing; only uploaded devices were processed:", + "exception_kind_failed": "Run failed", + "exception_delete": "Remove exception", + "exception_delete_failed": "Failed to remove exception", + "anno_timeline": "Annotation Timeline", + "slice_marker": "Slice Markers", + "new_slice": "New Slice", + "no_slices": "No slices yet", + "no_slices_hint": "Drag the timeline to create a marker range", + "current_slice": "Current Slice", + "enter_slice_name": "Enter a slice name", + "add_slice": "Add Slice", + "ai_annotate": "AI Annotate", + "unnamed_slice": "Untitled slice", + "slice_save_failed": "Save failed: ", + "slice_delete_failed": "Delete failed: ", + "inf_sec_model": "1. Model", + "inf_sec_data": "2. Test Data", + "inf_sec_run": "3. Run", + "inf_sec_result": "4. Results", + "inf_label_run": "Run", + "inf_label_step": "Checkpoint", + "inf_label_dataset": "Test dataset", + "inf_live": "live", + "inf_pending": "not wired", + "inf_no_params": "This checkpoint has no train_config.json; parameters unavailable.", + "inf_params_from": "From", + "inf_no_models": "No trained model yet", + "inf_dataset_none": "— Select a test dataset —", + "inf_datasets_available": "dataset(s) available", + "inf_no_datasets": "No exported dataset yet — export one from the review page first.", + "inf_run_btn": "Run inference", + "inf_run_disabled": "Backend execution is not wired up yet", + "inf_run_note": "Backend execution is not connected yet — this button is intentionally disabled.", + "inf_run_hint": "Once wired: runs the checkpoint over the test set and reports direction cosine, amplitude ratio, gripper MAE and the error curve.", + "inf_no_result": "No evaluation result yet." + } +}; diff --git a/processor/web/static/js/i18n.js b/processor/web/static/js/i18n.js index 75a36ea50060a39a8d409816c5a43a7d915ec7b2..92e96e030574108e89a55b299df1627813fdcaaf 100644 --- a/processor/web/static/js/i18n.js +++ b/processor/web/static/js/i18n.js @@ -1,492 +1,74 @@ /* i18n — 中英文切换 */ -const I18N = { - zh: { - app_title: 'processor', - video_review: '视频审核', - trash: '回收站', - all_status: '全部状态', - reviewing: '审核中', - reviewed: '已审核', - all_tasks: '全部任务', - search: '搜索...', - loading: '加载中...', - processing: '处理中...', - ai_annotating: 'AI 标注处理中...', - ai_annotating_hint: 'AI 标注完成后自动载入本批次数据', - no_data: '暂无数据', - load_failed: '加载失败', - select_episode: '选择一个视频开始审核 →', - no_video: '此 Episode 没有视频数据', - cameras: 'Cameras', - play_all: '▶ 全部播放', - pause_all: '⏸ 全部暂停', - play_btn: '播放', - pause_btn: '暂停', - layout_row: '并排', - layout_grid: '网格', - layout_column: '上下', - no_description: '无任务描述', - frame: '帧', - download: '下载', - export: '导出', - export_hint: '按工作流导出格式下载最终数据集', - re_export: '重新导出', - re_exporting: '重新导出中', - re_export_done: '重新导出完成', - re_export_failed: '重新导出失败', - delete: '删除', - approve: '通过', - deleted_msg: '已移入回收站', - select_hint: '选择 Episode 后显示详情', - trash_empty: '回收站为空', - trash_title: '回收站', - purge_all: '全部清空', - restore: '恢复', - permanent_delete: '永久删除', - days_left: '剩余', - day_unit: '天', - confirm_trash: '确定将此视频移入回收站吗?\n7 天后自动清理。', - confirm_permanent: '确定永久删除吗?此操作不可恢复!', - confirm_purge: '确定清空回收站吗?所有内容将被永久删除!', - op_failed: '操作失败', - restore_failed: '恢复失败', - delete_failed: '删除失败', - purge_failed: '清空失败', - export_failed: '导出失败', - no_reviewed: '没有已审核的 Episode 可导出', - export_created: '导出任务已创建,完成后自动下载', - left_hand: 'Left Hand', - right_hand: 'Right Hand', - single_mono: '单目视频', - glove_sensor: '手套传感器', - depth_image: '深度图', - aux_video: '辅助视频', - skeleton_suffix: '骨骼', - glove_suffix: '手套', - depth_suffix: '深度', - stereo_pair: '双目并排', - '3d_skeleton': '3D 骨骼', - stereo_render: '3D 拼接', - hand3d_no_data: '该帧无 3D 骨骼数据', - hand3d_hand0: '手 0', - hand3d_hand1: '手 1', - hand3d_world: '3D 手部世界坐标', - heatmap_toggle: '深度热力图', - synced_ok: '同步正常', - drag_here: '拖拽素材到这里', - remove_source: '移除素材', - video_suffix: '视频', - hand_pressure: '手部压力', - media_assets: '素材', - drag_to_canvas: '拖拽素材到中间视频区域', - raw_vs_processed: '原始视频 / 处理结果', - refresh: '刷新', - not_found: '未找到', - deleted: '已删除', - port_label: 'Port', - // Annotation - annotation: '动作标注', - annotation_readonly: '只读', - annotations: '标注段', - new_annotation: '新增标注', - edit_annotation: '编辑标注', - delete_annotation: '删除标注', - label_name: '标注名称', - start_frame: '起始帧', - end_frame: '结束帧', - set_start_frame: '设起始帧', - set_end_frame: '设结束帧', - keyframe: '关键帧', - add_keyframe: '添加关键帧', - frames_covered: '帧已标注', - per_frame_label: '每帧标注', - no_annotations: '暂无标注,点击"新增"创建第一个标注段', - confirm_delete_anno: '确定删除此标注段吗?', - save_annotation: '保存标注', - save_changes: '保存修改', - slice_conflict: '该切片已被其他设备修改,已加载最新版本,请重新确认后保存。', - current_frame: '当前帧', - anno_color: '颜色', - anno_frame_range: '帧范围', - anno_duration: '时长', - anno_keyframe_event: '关键帧事件', - seconds_abbr: 's', - // Batch - select_all: '全选', - batch_download: '批量下载', - batch_export_dataset: '导出数据集 (慢)', - batch_zip_download: '打包下载 (快)', - batch_zip_hint: '从源数据集原样裁出所选批次 —— 产出仍是单个 LeRobot 结构(data/ + meta/ + videos/),不重建、秒级返回。只想把这批数据拿走就用这个。', - batch_export_hint: '把所选批次合并成**一个**可训练数据集(需要整库重建:重编码视频 + 重算统计,约 30 秒/集)。要拿去训练才用这个。', - batch_selected_summary: '已选 %s 集 · %f 帧', - minutes_short: '分', - select_project_all: '全选本项目(含折叠中的批次)', - unreview: '撤销', - retry_review: '放入审核', - reprocess: '重新处理', - confirm_reprocess: '重新运行该批次绑定的工作流?新产物将替换旧处理结果。', - reprocess_failed: '重新处理失败: ', - // Sidebar & Nav - nav_home: '首页', - nav_tasks: '项目', - nav_review: '视频审核', - nav_trash: '回收站', - // Overview Dashboard - overview_title: 'Overview', - load_failed_stats: '加载统计数据失败', - retry: '重试', - stat_reviewing: '待审核', - stat_approved: '已通过', - stat_failed: '异常', - stat_total: '全部', - stat_anomaly: '异常', - recent_episodes: '最近 Episode', - view_all: '查看全部', - pipeline_status: 'Pipeline 状态', - // Task Management - new_task: '新建任务', - edit_task: '编辑任务', - task_name: '任务名称', - task_name_hint: '请使用字母/数字/下划线/中文,需与上传文件前缀匹配', - task_description: '描述', - claimer: '认领人', - target_episodes: '目标批次数', - target_duration: '目标时长(秒)', - fps: '帧率', - save: '保存', - cancel: '取消', - delete_task: '删除任务', - confirm_delete_task: '确定删除此任务吗?(已有采集数据将保留)', - episodes: 'Episodes', - frames: '帧', - no_frame_target: '无帧数目标', - devices: '设备', - online: '在线', - offline: '离线', - unknown: '未知', - last_seen: '最近活跃', - device_empty: '暂无设备接入', - expand_episodes: '查看 Episodes', - load_more: '加载更多', - no_tasks: '暂无任务,点击"新建任务"创建第一个采集任务', - all_status: '全部状态', - // Project management - projects_title: '项目', - search_projects: '搜索项目...', - new_project: '新建项目', - edit_project: '编辑项目', - project_name: '项目名称', - bind_workflow: '绑定工作流', - workflow_placeholder: '— 选择工作流 —', - device_type: '设备类型', - status: '状态', - no_projects: '暂无项目,点击右上角「新建项目」创建', - tasks_count: '任务', - videos_count: '视频', - ep_count: '条', - upload_new: '新批次', - upload_reupload: '重传', - upload_total: '总上传', - upload_unclassified: '历史未分类', - target_episodes: '目标条数', - target_unlimited: '不限', - no_tasks_in_project: '该项目下暂无任务(上传数据后自动归类)', - delete_project: '删除', - confirm_delete_project: '确定删除项目「', - confirm_delete_project_suffix: '」?项目下的任务/视频记录不会被删除。', - project_saved: '保存成功', - project_save_failed: '保存失败', - project_delete_failed: '删除失败', - project_load_failed: '加载失败', - unbound_workflow: '未绑定工作流', - workflow_edit: '编辑', - workflow_selected: '已选 %s 个工作流', - project_need_workflow: '项目设为 active 前必须先绑定至少一个工作流', - new_workflow_panel: '新建工作流', - new_workflow_prompt: '新工作流名称:', - workflow_create_failed: '创建工作流失败', - workflow_empty: '空', - template_badge: '模板', - project_saved_go_build: '项目已保存。是否前往工作流画布搭建/查看工作流?', - status_purged: '已彻底删除', - observed_inputs: '输入', - exceptions_badge: '异常', - exceptions: '异常', - exception_kind_mismatch: '输入不匹配', - exception_kind_missing: '设备数据缺失', - input_missing_title: '工作流设备数据不完整', - input_missing_detail: '以下设备缺失,本批次仅处理已上传设备:', - exception_kind_failed: '运行失败', - exception_delete: '删除异常', - exception_delete_failed: '删除异常失败', - anno_timeline: '标记时间轴', - slice_marker: '切片标记', - new_slice: '新建切片', - no_slices: '暂无切片标记', - no_slices_hint: '拖动时间轴后创建一个标记区间', - current_slice: '当前标记', - enter_slice_name: '请输入标记名称', - add_slice: '添加切片', - save_changes: '保存修改', - ai_annotate: 'AI 标注', - slice_conflict: '此切片已在另一台电脑修改,已加载最新版本。请确认后再次保存。', - unnamed_slice: '未命名切片', - slice_save_failed: '保存失败: ', - slice_delete_failed: '删除失败: ', - }, - en: { - app_title: 'processor', - video_review: 'Video Review', - trash: 'Trash', - all_status: 'All Status', - reviewing: 'Reviewing', - reviewed: 'Reviewed', - all_tasks: 'All Tasks', - search: 'Search...', - loading: 'Loading...', - processing: 'Processing...', - ai_annotating: 'AI annotation running...', - ai_annotating_hint: 'This batch becomes viewable once AI annotation finishes', - no_data: 'No data', - load_failed: 'Load failed', - select_episode: 'Select an episode to review →', - no_video: 'No video data', - cameras: 'Cameras', - play_all: '▶ Play All', - pause_all: '⏸ Pause All', - play_btn: 'Play', - pause_btn: 'Pause', - layout_row: 'Row', - layout_grid: 'Grid', - layout_column: 'Stack', - no_description: 'No description', - frame: 'frames', - download: 'Download', - export: 'Export', - export_hint: 'Download final dataset in workflow export format', - re_export: 'Re-export', - re_exporting: 'Re-exporting…', - re_export_done: 'Re-export done', - re_export_failed: 'Re-export failed', - delete: 'Delete', - approve: 'Approve', - deleted_msg: 'Moved to trash', - select_hint: 'Select an episode to view details', - trash_empty: 'Trash is empty', - trash_title: 'Trash', - purge_all: 'Purge All', - restore: 'Restore', - permanent_delete: 'Delete', - days_left: 'Remaining', - day_unit: 'days', - confirm_trash: 'Move to trash?\nAuto-cleaned after 7 days.', - confirm_permanent: 'Permanently delete? This cannot be undone!', - confirm_purge: 'Purge all trash? All items will be permanently deleted!', - op_failed: 'Operation failed', - restore_failed: 'Restore failed', - delete_failed: 'Delete failed', - purge_failed: 'Purge failed', - export_failed: 'Export failed', - no_reviewed: 'No reviewed episodes to export', - export_created: 'Export job created; it will download when ready', - left_hand: 'Left Hand', - right_hand: 'Right Hand', - single_mono: 'Single Camera', - glove_sensor: 'Glove Sensors', - depth_image: 'Depth Images', - aux_video: 'Aux Video', - skeleton_suffix: 'Skeleton', - glove_suffix: 'Glove', - depth_suffix: 'Depth', - stereo_pair: 'Stereo Pair', - '3d_skeleton': '3D Skeleton', - stereo_render: '3D Side-by-Side', - hand3d_no_data: 'No 3D skeleton data for this frame', - hand3d_hand0: 'Hand 0', - hand3d_hand1: 'Hand 1', - hand3d_world: '3D Hand World', - heatmap_toggle: 'Depth Heatmap', - synced_ok: 'Synced', - drag_here: 'Drag assets here', - remove_source: 'Remove', - video_suffix: 'Video', - hand_pressure: 'Hand Pressure', - media_assets: 'Assets', - drag_to_canvas: 'Drag assets onto the video area', - raw_vs_processed: 'Raw video / Processing results', - refresh: 'Refresh', - not_found: 'Not found', - deleted: 'Deleted', - port_label: 'Port', - // Annotation - annotation: 'Annotation', - annotation_readonly: 'Read only', - annotations: 'Annotations', - new_annotation: 'New Annotation', - edit_annotation: 'Edit Annotation', - delete_annotation: 'Delete Annotation', - label_name: 'Label Name', - start_frame: 'Start Frame', - end_frame: 'End Frame', - set_start_frame: 'Set Start', - set_end_frame: 'Set End', - keyframe: 'Keyframe', - add_keyframe: 'Add Keyframe', - frames_covered: 'frames labeled', - per_frame_label: 'per-frame label', - no_annotations: 'No annotations. Click "+" to create one.', - confirm_delete_anno: 'Delete this annotation segment?', - save_annotation: 'Save Annotation', - save_changes: 'Save Changes', - slice_conflict: 'This slice was modified by another device. The latest version was loaded — please confirm and save again.', - current_frame: 'Current Frame', - anno_color: 'Color', - anno_frame_range: 'Frame Range', - anno_duration: 'Duration', - anno_keyframe_event: 'Keyframe Event', - seconds_abbr: 's', - // Batch - select_all: 'Select All', - batch_download: 'Batch Download', - batch_export_dataset: 'Export dataset (slow)', - batch_zip_download: 'Zip download (fast)', - batch_zip_hint: 'Carves the selected episodes out of the source dataset as-is — still a single LeRobot structure (data/ + meta/ + videos/), no rebuild, returns in seconds. Use this to just take the data away.', - batch_export_hint: 'Merges the selected episodes into ONE trainable dataset (full rebuild: re-encode videos + recompute stats, ~30s/episode). Use this only for training.', - batch_selected_summary: '%s episodes · %f frames', - minutes_short: 'min', - select_project_all: 'Select all in this project (including collapsed)', - unreview: 'Unreview', - retry_review: 'Re-review', - reprocess: 'Reprocess', - confirm_reprocess: 'Re-run the bound workflows for this episode? New output will replace the old processing result.', - reprocess_failed: 'Reprocess failed: ', - // Sidebar & Nav - nav_home: 'Home', - nav_tasks: 'Projects', - nav_review: 'Video Review', - nav_trash: 'Trash', - // Overview Dashboard - overview_title: 'Overview', - load_failed_stats: 'Failed to load statistics', - retry: 'Retry', - stat_reviewing: 'Reviewing', - stat_approved: 'Approved', - stat_failed: 'Failed', - stat_total: 'All Episodes', - stat_anomaly: 'Anomaly', - recent_episodes: 'Recent Episodes', - view_all: 'View All', - pipeline_status: 'Pipeline Status', - // Task Management - new_task: 'New Task', - edit_task: 'Edit Task', - task_name: 'Task Name', - task_name_hint: 'Letters/digits/underscore/Chinese; must match zip filename prefix', - task_description: 'Description', - claimer: 'Claimer', - target_episodes: 'Target Batches', - target_duration: 'Target Duration (s)', - fps: 'FPS', - save: 'Save', - cancel: 'Cancel', - delete_task: 'Delete Task', - confirm_delete_task: 'Delete this task? (Existing episodes will be kept)', - episodes: 'Episodes', - frames: 'Frames', - no_frame_target: 'No frame target', - devices: 'Devices', - online: 'Online', - offline: 'Offline', - unknown: 'Unknown', - last_seen: 'Last Seen', - device_empty: 'No devices connected', - expand_episodes: 'View Episodes', - load_more: 'Load More', - no_tasks: 'No tasks yet. Click "New Task" to create one.', - all_status: 'All Status', - // Project management - projects_title: 'Projects', - search_projects: 'Search projects...', - new_project: 'New Project', - edit_project: 'Edit Project', - project_name: 'Project Name', - bind_workflow: 'Workflow', - workflow_placeholder: '— Select workflow —', - device_type: 'Device Type', - status: 'Status', - no_projects: 'No projects yet. Click "New Project" to create one.', - tasks_count: 'tasks', - videos_count: 'videos', - ep_count: 'episodes', - upload_new: 'new batches', - upload_reupload: 'reuploads', - upload_total: 'total uploads', - upload_unclassified: 'legacy unclassified', - target_episodes: 'Target Episodes', - target_unlimited: 'unlimited', - no_tasks_in_project: 'No tasks under this project (uploads are grouped automatically).', - delete_project: 'Delete', - confirm_delete_project: 'Delete project "', - confirm_delete_project_suffix: '"? Task/episode records are kept.', - project_saved: 'Saved', - project_save_failed: 'Save failed', - project_delete_failed: 'Delete failed', - project_load_failed: 'Load failed', - unbound_workflow: 'No workflow bound', - workflow_edit: 'Edit', - workflow_selected: '%s workflows selected', - project_need_workflow: 'Bind at least one workflow before activating this project', - new_workflow_panel: '+ New Workflow', - new_workflow_prompt: 'Name for the new workflow:', - workflow_create_failed: 'Failed to create workflow', - workflow_empty: 'empty', - template_badge: 'Template', - project_saved_go_build: 'Project saved. Go to Workflow Studio to build/view the workflow?', - status_purged: 'Purged', - observed_inputs: 'Inputs', - exceptions_badge: 'exceptions', - exceptions: 'Exceptions', - exception_kind_mismatch: 'Input mismatch', - exception_kind_missing: 'Missing device data', - input_missing_title: 'Workflow inputs are incomplete', - input_missing_detail: 'The following devices are missing; only uploaded devices were processed:', - exception_kind_failed: 'Run failed', - exception_delete: 'Remove exception', - exception_delete_failed: 'Failed to remove exception', - anno_timeline: 'Annotation Timeline', - slice_marker: 'Slice Markers', - new_slice: 'New Slice', - no_slices: 'No slices yet', - no_slices_hint: 'Drag the timeline to create a marker range', - current_slice: 'Current Slice', - enter_slice_name: 'Enter a slice name', - add_slice: 'Add Slice', - save_changes: 'Save Changes', - ai_annotate: 'AI Annotate', - slice_conflict: 'This slice was changed on another computer. The latest version has been loaded.', - unnamed_slice: 'Untitled slice', - slice_save_failed: 'Save failed: ', - slice_delete_failed: 'Delete failed: ', - } -}; +// 词典**不在这里** —— 见同目录的 i18n-data.js。 +// 它同时被服务端读取(app/i18n.py),而服务端是 Python;两边读同一份才不会分叉。 +// 那个文件在 base.html 里**先于本文件**同步加载,所以这里读得到。 +// 真读不到(比如有人单独引这个脚本)时退化成空字典 —— 页面照常起得来, +// t() 会返回 key 本身,一眼看得出是加载顺序出了问题。 +const I18N = (typeof window !== 'undefined' && window.__I18N__) || { zh: {}, en: {} }; + +/** + * 客户端语言**以服务端渲染时用的那个为准** —— 它就写在 `` 上。 + * + * ★ 不能只读 localStorage。服务端是从 **cookie** 读语言的,两者可能不一致 + * (比如清了 localStorage 但 cookie 还在)。一旦不一致,下面的 + * applyTranslations() 会把服务端刚渲染好的中文**又翻回英文** —— 页面反而 + * 变成了"先中文后英文",比原来的闪烁更难看。 + * + * 以 为准还有个好处:它和 DOM 里已有的文案**必然一致**(服务端就是 + * 按它渲染的),所以首次 applyTranslations() 是个空操作。 + * + * 里的脚本执行时 已经解析完了,所以这里读得到。 + */ +function _initialLang() { + const fromHtml = String(document.documentElement.lang || '').toLowerCase(); + if (I18N[fromHtml]) return fromHtml; + try { + const saved = localStorage.getItem('lang'); + if (saved && I18N[saved]) return saved; + } catch (e) { /* 隐私模式禁 storage */ } + return 'en'; +} -let currentLang = 'en'; +let currentLang = _initialLang(); function t(key) { return (I18N[currentLang] && I18N[currentLang][key]) || I18N['zh'][key] || key; } -function setLang(lang) { - currentLang = lang; - localStorage.setItem('lang', lang); - // Re-apply all data-i18n text +/** 把静态标注(data-i18n / data-i18n-placeholder)应用到 DOM。 */ +function applyTranslations() { document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); }); document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nPlaceholder); }); + // title 属性此前没人处理 —— 只有 data-i18n(写 textContent)和 placeholder 两种, + // 于是带提示的按钮/图标没法靠标记翻译,只能写死中文。 + document.querySelectorAll('[data-i18n-title]').forEach(el => { + el.title = t(el.dataset.i18nTitle); + }); +} + +function setLang(lang) { + if (!I18N[lang]) return; // 只认字典里有的语言 + currentLang = lang; + try { localStorage.setItem('lang', lang); } catch (e) { /* 隐私模式禁 storage */ } + // ★ 同时写 cookie —— **服务端渲染时要读它**,否则每次整页导航都会先渲染 + // 默认语言、再由 JS 纠正,表现为"闪一下另一种语言"。 + // localStorage 在这里已经不够用了:它是浏览器本地的,服务端看不到。 + // max-age 一年;SameSite=Lax 免得外站发起的请求也带上这个 cookie。 + try { + document.cookie = `lang=${encodeURIComponent(lang)}; path=/; max-age=31536000; SameSite=Lax`; + } catch (e) { /* 禁 cookie:退回旧行为,靠上面的 applyTranslations() 事后翻 */ } + // 通知**同页面里的其他应用**:工作流画布是独立的 React 应用,不加载本文件, + // 它靠这个事件跟着切。不能用 storage 事件 —— 那个只在**别的标签页**触发, + // 而切换按钮就在本页侧边栏上。 + try { + window.dispatchEvent(new CustomEvent('egodata:lang', { detail: { lang } })); + } catch (e) { /* 老浏览器不支持 CustomEvent 构造:画布不跟随,其余照常 */ } + applyTranslations(); // Update header nav const navReview = document.getElementById('nav-review'); const navTrash = document.getElementById('nav-trash'); @@ -503,9 +85,12 @@ function setLang(lang) { if (typeof loadDevices === 'function') loadDevices(); if (typeof loadEpisodes === 'function') loadEpisodes(); if (typeof loadTrashList === 'function') loadTrashList(); - if (typeof loadExportJobs === 'function') loadExportJobs(); if (typeof renderSourceBar === 'function') renderSourceBar(); if (typeof updateInfoBar === 'function') updateInfoBar(); + // 首页概览的环形图/柱状图/状态徽标都是 JS 现画的,不重跑就不跟着切语言 + if (typeof refreshDashboard === 'function') refreshDashboard(); + // 用户列表同样是 JS 现画的(表头是静态标注,行内容是动态的) + if (typeof loadUsers === 'function') loadUsers(); updateLangToggle(); } @@ -514,16 +99,27 @@ function updateLangToggle() { if (btn) { btn.textContent = currentLang === 'zh' ? 'EN' : '中'; } + // 用户菜单里的语言选项:高亮当前项。用的是 class 切换而不是重建 DOM —— + // 菜单是常驻元素,重建会丢掉挂在它上面的监听。 + document.querySelectorAll('[data-lang-option]').forEach(el => { + const active = el.dataset.langOption === currentLang; + el.setAttribute('aria-pressed', active ? 'true' : 'false'); + el.classList.toggle('text-cyan-300', active); + el.classList.toggle('border-cyan-700', active); + el.classList.toggle('bg-cyan-900/30', active); + el.classList.toggle('text-gray-400', !active); + el.classList.toggle('border-gray-700', !active); + }); } -// Apply translations on every page at load. Default language is English; -// the toggle (lang-toggle) switches to Chinese for the session. +// Apply translations on every page at load. +// +// ★ 必须**先读回上次选的语言**:setLang 一直在写 localStorage,但此前没有任何 +// 地方读它 —— currentLang 写死 'en',于是每次刷新都退回英文,切换等于没切。 document.addEventListener('DOMContentLoaded', () => { - document.querySelectorAll('[data-i18n]').forEach(el => { - el.textContent = t(el.dataset.i18n); - }); - document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { - el.placeholder = t(el.dataset.i18nPlaceholder); - }); + // currentLang 在上面就用 _initialLang() 定好了(以 为准), + // 这里**不再从 localStorage 覆盖** —— 那正是会把服务端渲染好的语言翻回去的操作。 + // 语言由服务端按 cookie 渲染,客户端只负责后续切换。 + applyTranslations(); updateLangToggle(); }); diff --git a/processor/web/static/js/inference/view.js b/processor/web/static/js/inference/view.js new file mode 100644 index 0000000000000000000000000000000000000000..e439713e36566435f2b7b522272bed723724a69d --- /dev/null +++ b/processor/web/static/js/inference/view.js @@ -0,0 +1,235 @@ +/** 模型推理视图 —— 选模型/检查点、看它的训练参数、选测试集,然后跑评估。 + * + * 与 `static/js/train/` 同构:一个入口 + 若干模块,共用 `static/js/shared/` 下的 + * 纯工具。**不新开一套** —— 推理和训练本来就是同一批产物的上下游。 + * + * **后端执行未接** —— 「运行推理」按钮和训练页一样是**故意禁用**的,旁边写明原因。 + * 这一页真正能用的是前面三段(选模型 / 看参数 / 选测试集),数据都是真的: + * 模型与检查点 → GET /api/v1/training/runs + * 训练参数 → 那些 checkpoint 里的 train_config.json + * 测试集 → GET /api/v1/training/datasets + * + * 没有「实时日志」「指标曲线」之类的空页签 —— 没有数据的页签不摆,理由同训练页。 + * + * ★ 数据源为什么直接引 `../train/api.js`:runs / datasets 这两个端点属于**训练域** + * (推理消费的是训练产物)。等将来有第三个页面也用到它们,再抽到 shared/ 去。 + */ + +import { el, clear } from '../shared/dom.js'; +import { tr } from '../shared/text.js'; +import { listDatasets, listRuns } from '../train/api.js'; + +const INPUT_CLASS = 'bg-gray-800 text-xs rounded px-2 py-1 border border-gray-700 ' + + 'focus:border-blue-500 focus:outline-none font-mono'; +const BTN = 'bg-gray-800 hover:bg-gray-700 text-gray-300 text-xs px-2.5 py-1 ' + + 'rounded border border-gray-700'; + +const LIVE = 'bg-green-900/50 text-green-300'; +const PENDING = 'bg-amber-900/50 text-amber-300'; + +function section(titleKey, fallback, badge, children) { + return el('div', { class: 'bg-gray-900 rounded-lg border border-gray-800 p-4 mb-4' }, [ + el('div', { class: 'flex items-center gap-3 mb-3' }, [ + el('div', { class: 'text-sm font-semibold text-gray-200', text: tr(titleKey, fallback) }), + badge ? el('span', { class: `text-[10px] px-1.5 py-0.5 rounded ${badge.cls}`, text: badge.text }) : null, + ]), + ...children, + ]); +} + +function labeled(label, control, labelClass = 'text-gray-500 shrink-0 w-32') { + return el('div', { class: 'flex items-center gap-3 text-xs' }, [ + el('span', { class: labelClass, text: label }), + control, + ]); +} + +function hint(key, fallback) { + return el('div', { class: 'text-xs text-gray-600 mt-3', text: tr(key, fallback) }); +} + +/** 一行「参数名 → 值」。推理前最该确认的就是**训练时用的 chunk_size / + * n_action_steps** 这类会直接影响推理输出的参数,所以这里只挑这几个关键的, + * 完整的训练参数在训练页那个「训练参数」页签里有。 */ +function paramRow(name, value) { + return el('div', { class: 'flex items-baseline gap-3 text-xs py-0.5' }, [ + el('span', { class: 'text-gray-500 shrink-0 w-32 font-mono', text: name }), + el('span', { class: 'text-gray-200 font-mono break-all', text: value ?? '—' }), + ]); +} + +const KEY_PARAMS = [ + 'policy.type', 'policy.chunk_size', 'policy.n_action_steps', + 'policy.dim_model', 'policy.vision_backbone', +]; + +function pick(obj, path) { + return path.split('.').reduce((a, k) => (a === null || a === undefined ? undefined : a[k]), obj); +} + +/** + * @param {HTMLElement} container + * @param {{runName?: string, checkpointName?: string}} [preselect] + * 从「模型训练」页签点某个检查点的「推理」跳过来时带上 —— 直接选中它, + * 不用再手动在下拉里找一遍。 + */ +export function renderInference(container, preselect) { + clear(container); + + const state = { run: null, checkpoint: null, dataset: null }; + + // ── 选择模型 / 检查点 ──────────────────────────────── + const runSelect = el('select', { class: INPUT_CLASS + ' w-64' }); + const stepSelect = el('select', { class: INPUT_CLASS + ' w-40', disabled: true }); + const paramHost = el('div', { class: 'mt-3' }); + let runs = []; + + function renderCheckpointParams() { + clear(paramHost); + const config = state.checkpoint?.train_config; + if (!config || !Object.keys(config).length) { + paramHost.appendChild(el('div', { + class: 'text-xs text-gray-600 py-2', + text: tr('inf_no_params', 'This checkpoint has no train_config.json; parameters unavailable.'), + })); + return; + } + paramHost.appendChild(el('div', { + class: 'text-[10px] text-gray-600 mb-2', + text: `${tr('inf_params_from', 'From')} checkpoints/${state.checkpoint.name}/pretrained_model/train_config.json`, + })); + paramHost.appendChild(el('div', { class: 'border border-gray-800 rounded p-3' }, + KEY_PARAMS.map((p) => paramRow(p, pick(config, p))))); + } + + function fillSteps(wantedStep) { + clear(stepSelect); + const run = runs.find((r) => r.name === runSelect.value); + if (!run) { + stepSelect.disabled = true; + stepSelect.appendChild(el('option', { value: '', text: '—' })); + state.checkpoint = null; + renderCheckpointParams(); + return; + } + stepSelect.disabled = false; + for (const c of run.checkpoints) { + stepSelect.appendChild(el('option', { value: c.name, text: `${c.name} (${c.step})` })); + } + // 有预选就用预选,否则用最新的那个。预选名可能对不上(比如那个 checkpoint + // 刚被删),对不上就退回最新 —— 不要因为一个过期的参数让整页空着。 + const pick = (wantedStep && run.checkpoints.find((c) => c.name === wantedStep)) + || run.checkpoints[0] || null; + state.checkpoint = pick; + if (pick) stepSelect.value = pick.name; + renderCheckpointParams(); + } + + runSelect.addEventListener('change', () => { + state.run = runSelect.value || null; + fillSteps(); + }); + stepSelect.addEventListener('change', () => { + const run = runs.find((r) => r.name === runSelect.value); + state.checkpoint = (run?.checkpoints || []).find((c) => c.name === stepSelect.value) || null; + renderCheckpointParams(); + }); + + const sectionModel = section('inf_sec_model', '1. Model', null, [ + el('div', { class: 'flex gap-6 flex-wrap' }, [ + labeled(tr('inf_label_run', 'Run'), runSelect), + labeled(tr('inf_label_step', 'Checkpoint'), stepSelect), + ]), + paramHost, + ]); + paramHost.appendChild(el('div', { class: 'text-xs text-gray-600 py-2', text: tr('loading', 'Loading...') })); + + listRuns() + .then((payload) => { + runs = payload.runs || []; + clear(runSelect); + if (!runs.length) { + runSelect.appendChild(el('option', { value: '', text: tr('inf_no_models', 'No trained model yet') })); + renderCheckpointParams(); + return; + } + for (const r of runs) { + runSelect.appendChild(el('option', { value: r.name, text: `${r.name} (${r.checkpoint_count})` })); + } + const chosen = runs.find((r) => r.name === preselect?.runName) || runs[0]; + runSelect.value = chosen.name; + state.run = chosen.name; + fillSteps(preselect?.checkpointName); + }) + .catch((err) => { + clear(paramHost); + paramHost.appendChild(el('div', { + class: 'text-xs text-red-400 py-2', + text: `${tr('train_load_failed', 'Failed to load')}: ${err.message}`, + })); + }); + + // ── 测试集 ────────────────────────────────────────── + const datasetSelect = el('select', { class: INPUT_CLASS + ' w-80' }, [ + el('option', { value: '', text: tr('inf_dataset_none', '— Select a test dataset —') }), + ]); + datasetSelect.addEventListener('change', () => { + const value = datasetSelect.value; + state.dataset = value ? { name: value } : null; + }); + const datasetNote = el('div', { class: 'text-xs text-gray-600 mt-2' }); + + const sectionData = section('inf_sec_data', '2. Test Data', + { cls: LIVE, text: tr('inf_live', 'live') }, [ + labeled(tr('inf_label_dataset', 'Test dataset'), datasetSelect), + datasetNote, + ]); + datasetNote.textContent = tr('loading', 'Loading...'); + + listDatasets() + .then((payload) => { + const items = payload.datasets || []; + datasetNote.textContent = items.length + ? `${items.length} ${tr('inf_datasets_available', 'dataset(s) available')}` + : tr('inf_no_datasets', 'No exported dataset yet — export one from the review page first.'); + for (const d of items) { + datasetSelect.appendChild(el('option', { + value: d.name, + text: `${d.name} — ${d.total_episodes} ${tr('train_episodes', 'episodes')}`, + })); + } + }) + .catch(() => { datasetNote.textContent = ''; }); + + // ── 运行 ──────────────────────────────────────────── + const sectionRun = section('inf_sec_run', '3. Run', { cls: PENDING, text: tr('inf_pending', 'not wired') }, [ + el('div', { class: 'flex items-center gap-3' }, [ + el('button', { + class: 'bg-blue-600/40 text-blue-200/60 text-sm px-5 py-2 rounded cursor-not-allowed', + text: tr('inf_run_btn', 'Run inference'), + disabled: true, + title: tr('inf_run_disabled', 'Backend execution is not wired up yet'), + }), + el('span', { + class: 'text-xs text-amber-400', + text: tr('inf_run_note', 'Backend execution is not connected yet — this button is intentionally disabled.'), + }), + ]), + hint('inf_run_hint', 'Once wired, this runs the checkpoint over the chosen test set and reports direction cosine, amplitude ratio, gripper MAE and the error curve.'), + ]); + + // ── 结果 ──────────────────────────────────────────── + const sectionResult = section('inf_sec_result', '4. Results', null, [ + el('div', { class: 'text-center py-8' }, [ + el('div', { class: 'text-xs text-gray-600', text: tr('inf_no_result', 'No evaluation result yet.') }), + ]), + ]); + + for (const node of [sectionModel, sectionData, sectionRun, sectionResult]) { + container.appendChild(node); + } +} + +// 这里**没有** boot/自启动 —— 本模块不再是页面入口,而是被 train/main.js +// 当作第三个页签 import 进去(`render: renderInference`)。 +// 页签的 render 签名是 (panel, run),本函数只用到 panel,多收一个参数无妨。 diff --git a/processor/web/static/js/player.js b/processor/web/static/js/player.js index 2c1d327c0a221cd9d8856b712e31bf734eb29ef7..568dacb3543e232bb229fa9e5eb47f7fbec765c9 100644 --- a/processor/web/static/js/player.js +++ b/processor/web/static/js/player.js @@ -2003,7 +2003,7 @@ async function mountGroupedSource(source, tile) { const curveCaption = document.createElement('div'); curveCaption.className = 'absolute pointer-events-none'; curveCaption.style.cssText = 'top:2px;left:6px;font-size:10px;color:#64748b;'; - curveCaption.textContent = '力 mN'; + curveCaption.textContent = t('force_unit_mn'); curveBox.appendChild(curveCaption); holder.appendChild(matrixBox); holder.appendChild(curveBox); @@ -2014,7 +2014,7 @@ async function mountGroupedSource(source, tile) { chunks: new Map(), chunkInflight: new Set(), frameCount: Number(tactileMatrixData?.frame_count) || 0 }; _resetTactileView(renderer); - canvas.title = '接触力场(固定全幅,不可缩放)'; + canvas.title = t('force_field_title'); currentTactileTiles.push(entry); const frame = typeof currentFrameTarget === 'number' ? currentFrameTarget : 0; _updateTactileForceTile(entry, frame); diff --git a/processor/web/static/js/shared/dom.js b/processor/web/static/js/shared/dom.js new file mode 100644 index 0000000000000000000000000000000000000000..3d4739792a0b0166bad52abc155cd97281b64c8d --- /dev/null +++ b/processor/web/static/js/shared/dom.js @@ -0,0 +1,69 @@ +/** 极小的 DOM 构造 helper —— 一律 createElement + textContent。 + * + * 为什么不用 innerHTML 拼字符串:checkpoint 的文件名、路径、后端错误信息都 + * 来自磁盘和网络,拼进 HTML 就是一个转义面。仓库里 `escHtml()` 已经有**四份 + * 实现**,其中 dashboard.js 那份还漏了 `String()` 转换 —— 与其再加第五份, + * 不如让新代码从根上不产生转义需求。 + * + * 用法: + * el('div', { class: 'p-2', text: 'hi' }, [el('span', { text: 'x' })]) + */ + +const ATTR_ALIASES = { class: 'class', for: 'for', style: 'style' }; + +/** 必须走 setAttribute 的键(即使不含连字符)。 + * + * `icon` 是给 `` 这个**自定义元素**用的:它只监听 attribute + * 变化。`node.icon = '...'` 只是给元素挂了个 JS 属性,不反映成 attribute, + * 结果就是图标位置一片空白 —— 而且不报错,很难查。 */ +const SET_ATTR = new Set(['icon']); + +/** + * @param {string} tag + * @param {object} [attrs] `text` 走 textContent;`on` 是 {事件名: 处理函数}; + * `dataset` 是 {键: 值};其余按属性 setAttribute。 + * @param {Array} [children] 子节点(字符串会被转成文本节点) + */ +export function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === null || value === false) continue; + + if (key === 'text') { + node.textContent = String(value); + } else if (key === 'on') { + for (const [event, handler] of Object.entries(value)) { + node.addEventListener(event, handler); + } + } else if (key === 'dataset') { + for (const [k, v] of Object.entries(value)) node.dataset[k] = String(v); + } else if (ATTR_ALIASES[key] || SET_ATTR.has(key) || key.includes('-')) { + node.setAttribute(key, String(value)); + } else { + node[key] = value; + } + } + + append(node, children); + return node; +} + +/** 把 children(数组或单值)追加进 node。null/undefined/false 会被跳过, + * 这样 `cond && el(...)` 这种写法可以直接塞进数组。 */ +export function append(node, children) { + const list = Array.isArray(children) ? children : [children]; + for (const child of list) { + if (child === null || child === undefined || child === false) continue; + node.appendChild( + child instanceof Node ? child : document.createTextNode(String(child)), + ); + } + return node; +} + +/** 清空一个容器 —— 比 `innerHTML = ''` 明确,也不会触发 HTML 解析。 */ +export function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); + return node; +} diff --git a/processor/web/static/js/shared/format.js b/processor/web/static/js/shared/format.js new file mode 100644 index 0000000000000000000000000000000000000000..6a466477cf130d94b25c76bab6c3a919f1ebca39 --- /dev/null +++ b/processor/web/static/js/shared/format.js @@ -0,0 +1,46 @@ +/** 展示层格式化工具 —— 纯函数,不碰 DOM、不发请求。 + * + * 单独成文件的原因:checkpoints 与 params 两个视图都要用,而它们之间不该 + * 互相依赖。放在这里,两边都只依赖这一个叶子模块。 + */ + +/** + * 字节数 → 人类可读。1024 进制,和后端报的 total_bytes 口径一致。 + * 0 与非法值都返回 '0 B'(不返回 NaN —— 它会在表格里显示成 "NaN MB")。 + */ +export function bytes(n) { + const v = Number(n); + if (!Number.isFinite(v) || v <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let x = v; + while (x >= 1024 && i < units.length - 1) { + x /= 1024; + i += 1; + } + // 字节和 KB 不留小数(4.0 KB 不如 4 KB 好读),MB 以上留一位。 + return `${i <= 1 ? Math.round(x) : x.toFixed(1)} ${units[i]}`; +} + +/** + * Unix 秒 → `YYYY-MM-DD HH:MM`。 + * + * 刻意不用 toLocaleString:它受浏览器语言影响,中英切换时同一个时间会变成 + * 两种格式,表格列宽跟着跳。这里固定格式,与时区无关的部分保持稳定。 + */ +export function dateTime(seconds) { + const v = Number(seconds); + if (!Number.isFinite(v) || v <= 0) return '—'; + const d = new Date(v * 1000); + if (Number.isNaN(d.getTime())) return '—'; + const p = (x) => String(x).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` + + `${p(d.getHours())}:${p(d.getMinutes())}`; +} + +/** 大整数 → 千分位。20000 显示成 20,000,免得数零。 */ +export function thousands(n) { + const v = Number(n); + if (!Number.isFinite(v)) return '—'; + return v.toLocaleString('en-US'); +} diff --git a/processor/web/static/js/shared/text.js b/processor/web/static/js/shared/text.js new file mode 100644 index 0000000000000000000000000000000000000000..4c01d981d73621dfe04d2d09876640dde759c0a1 --- /dev/null +++ b/processor/web/static/js/shared/text.js @@ -0,0 +1,17 @@ +/** i18n 桥接 —— 把经典脚本 i18n.js 的全局 `t()` 包一层。 + * + * 为什么要单独一个文件:应用的词典在 `static/js/i18n.js`,那是个**经典脚本** + * (没有 export),ES module 里 `import ... from '../i18n.js'` 拿不到任何东西。 + * 与其让每个视图自己去摸 `window.t`,不如集中在这一处,顺带做防御: + * + * 页面照常渲染优先于文案语言正确 —— i18n.js 万一没加载(脚本 404、加载顺序 + * 出问题),退回英文而不是抛异常、白屏。三个视图都依赖本模块,所以只写一份。 + */ + +/** + * @param {string} key 词典键 + * @param {string} fallback 取不到时的英文兜底 + */ +export function tr(key, fallback) { + return typeof window.t === 'function' ? window.t(key) : fallback; +} diff --git a/processor/web/static/js/slice-preview.js b/processor/web/static/js/slice-preview.js index 389813ca5dfb8f1bd5f97bd1b0bd5aad58a23b9a..e4922c1a8920cc4d374a2328798ca6c66b6effbf 100644 --- a/processor/web/static/js/slice-preview.js +++ b/processor/web/static/js/slice-preview.js @@ -181,7 +181,7 @@ const boundaryBad = seg.boundary_ok === false || seg.label_matched === false; mid.innerHTML = `
- ${isCandidate ? '✨ ' : ''}${boundaryBad ? '⚠ ' : ''}${globalIdx + 1} · ${esc(seg.label || '—')} + ${isCandidate ? '✨ ' : ''}${boundaryBad ? '⚠ ' : ''}${globalIdx + 1} · ${esc(seg.label || '—')}
${seg.start_frame_index}-${seg.end_frame_index} · ${(seg.start_frame_index / fps).toFixed(1)}s diff --git a/processor/web/static/js/train/api.js b/processor/web/static/js/train/api.js new file mode 100644 index 0000000000000000000000000000000000000000..e1bd1682094135ed10e81036622a6d41d16eb63b --- /dev/null +++ b/processor/web/static/js/train/api.js @@ -0,0 +1,86 @@ +/** 训练页的后端调用 —— 全页只有这里出现 `fetch`。 + * + * **这里刻意没有**提交训练 / 停止 / 删除 / 下载。本期不接后端执行,与其放一个 + * 点了会 404 的函数,不如等端点真正存在时再加进来 —— 视图层也就不会误以为 + * 它们可用。将来接执行时,新端点加在本文件顶部即可,形状保持一致。 + * + * 对应后端:`app/routes/training.py`(只读,不执行任何训练)。 + */ + +const BASE = '/api/v1/training'; + +async function getJSON(url) { + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!res.ok) { + // 后端的错误体是 {detail: ...}。取不到就退回状态码 —— 不要吞成空消息, + // 否则页面上只剩一个没有原因的空白。 + let detail = ''; + try { + detail = (await res.json()).detail || ''; + } catch { + /* 非 JSON 响应(网关/代理错误页),用状态码兜底 */ + } + throw new Error(detail || `${res.status} ${res.statusText}`); + } + return res.json(); +} + +/** + * 列出训练产物。 + * + * @returns {Promise<{runs: Array, root: string, exists: boolean}>} + * `exists: false` 表示训练目录不存在(新机器、或本机从未跑过训练)。 + * 这是**正常情况**,由调用方渲染空态,不要当错误抛。 + */ +export function listRuns() { + return getJSON(`${BASE}/runs`); +} + +/** + * 单个 checkpoint 文件的下载地址 —— 给 `` 用的**字符串**,不是请求。 + * + * 路由是 ``/runs/{run}/checkpoints/{step}/files/{name:path}``,``{name:path}`` 要收 + * 子目录路径(``pretrained_model/model.safetensors``),所以**整体保留斜杠、只逐段 + * 编码**;整串 encodeURIComponent 会把斜杠也编掉,后端就找不到文件。 + */ +export function checkpointFileUrl(runName, step, name) { + const seg = encodeURIComponent; + const path = String(name).split('/').map(seg).join('/'); + return `${BASE}/runs/${seg(runName)}/checkpoints/${seg(step)}/files/${path}`; +} + +/** + * 本机 GPU 列表(读 nvidia-smi)。 + * + * @returns {Promise<{available: boolean, error: string, gpus: Array}>} + * `available: false` 表示**问不到显卡**(驱动没起来 / 没装 nvidia-smi), + * 与"显卡闲着"完全是两回事 —— 调用方必须区别显示,不能当 0 用。 + */ +export function listGpus() { + return getJSON(`${BASE}/gpus`); +} + +/** + * 可训练的数据集 = `sessions/<项目>` 下的多集 LeRobot 树。 + * + * 不是 `state/exports/*`:那里每条都是单集导出(total_episodes=1),凑不成训练集。 + */ +export function listDatasets() { + return getJSON(`${BASE}/datasets`); +} + +/** + * 各项目离「可以导出」还有多远 —— 训练页的「到点提醒」用。 + * + * 判据是**已通过集数达到项目设置的采集上限**(`params.target_episodes`), + * 到点只提醒、不自动导出 —— 理由见 `app/training/projects.py` 的 docstring。 + * + * @returns {Promise<{projects: Array, ready_count: number}>} + */ +export function listReadyProjects() { + return getJSON(`${BASE}/ready`); +} + +// 注:这里曾经有一个 listEpisodes()(走 /api/v1/ingestion/episodes 逐个勾选 +// episode)。改成"按数据集(项目)选"之后就没人用了,已删除 —— 等真要做 +// "从已审核的集组装训练集"那一步时再加回来,那时它多半还要带上筛选参数。 diff --git a/processor/web/static/js/train/checkpoints.js b/processor/web/static/js/train/checkpoints.js new file mode 100644 index 0000000000000000000000000000000000000000..7ca8f6c2d613ecbea0666bb15d148dfccb0be709 --- /dev/null +++ b/processor/web/static/js/train/checkpoints.js @@ -0,0 +1,152 @@ +/** 「模型输出」页签 —— 每个 checkpoint 一个可展开块 + 文件明细表。 + * + * 数据全部来自磁盘真实文件(后端 rglob 出来的),没有任何凑数的假行。 + * + * **下载已经能用了** —— 文件表逐行一个链接,走后端 + * ``GET /runs/{run}/checkpoints/{step}/files/{name:path}``(逐文件而不是整包: + * 一个 checkpoint 约 600MB,其中 training_state/ 只在续训时有用)。 + * + * **其余操作按钮仍然 disabled**:删除 / 推理 / 离线部署都还没有端点。做成可点 + * 但没反应是最糟的 —— 用户会反复点、以为坏了。disabled 加上 title 说明,一眼就 + * 知道"还没接",和有端点了再放开是两回事。 + */ + +import { el, clear } from '../shared/dom.js'; +import { bytes, dateTime } from '../shared/format.js'; +import { tr } from '../shared/text.js'; +import { checkpointFileUrl } from './api.js'; + +/** 逐个文件一行。文件名可能很长(pretrained_model/xxx.safetensors), + * 用 break-all 而不是省略号 —— 训练产物看的就是完整名字。 */ +function fileRow(runName, checkpoint, file) { + return el('tr', { class: 'border-b border-gray-800/60 last:border-0' }, [ + el('td', { class: 'py-1.5 pr-4 text-gray-300 font-mono text-[11px] break-all', text: file.name }), + el('td', { class: 'py-1.5 pr-4 text-gray-400 text-right whitespace-nowrap', text: bytes(file.size) }), + el('td', { class: 'py-1.5 pr-4 text-gray-500 whitespace-nowrap', text: dateTime(file.mtime) }), + el('td', { class: 'py-1.5 text-right whitespace-nowrap' }, [ + // 带 download 属性:浏览器直接存盘,不跳转到一个只会显示二进制内容的页面。 + el('a', { + class: 'text-blue-400 hover:underline', + href: checkpointFileUrl(runName, checkpoint.name, file.name), + download: true, + text: tr('train_btn_download', 'Download'), + }), + ]), + ]); +} + +function fileTable(runName, checkpoint) { + return el('table', { class: 'w-full text-xs mt-3' }, [ + el('thead', {}, [ + el('tr', { class: 'border-b border-gray-800' }, [ + el('th', { class: 'text-left py-1.5 pr-4 font-medium text-gray-500', text: tr('train_col_name', 'Name') }), + el('th', { class: 'text-right py-1.5 pr-4 font-medium text-gray-500', text: tr('train_col_size', 'Size') }), + el('th', { class: 'text-left py-1.5 pr-4 font-medium text-gray-500', text: tr('train_col_mtime', 'Modified') }), + el('th', { class: 'text-right py-1.5 font-medium text-gray-500' }), + ]), + ]), + el('tbody', {}, checkpoint.files.map((f) => fileRow(runName, checkpoint, f))), + ]); +} + +/** **真的还没接端点**的按钮 —— disabled,title 说明原因。 */ +function pendingButton(label, hint) { + return el('button', { + class: 'text-[11px] px-2 py-1 rounded border border-gray-700 text-gray-600 cursor-not-allowed', + text: label, + disabled: true, + title: hint, + }); +} + +/** 能点的按钮。 */ +function actionButton(label, hint, onClick) { + return el('button', { + class: 'text-[11px] px-2 py-1 rounded border border-gray-600 text-gray-300 ' + + 'hover:border-gray-500 hover:text-gray-100', + title: hint, + text: label, + on: { click: onClick }, + }); +} + +function checkpointBlock(runName, checkpoint, expanded, handlers) { + const body = el('div', { class: expanded ? '' : 'hidden' }, [fileTable(runName, checkpoint)]); + const chevron = el('span', { + class: 'text-gray-500 text-[10px] transition-transform', + text: expanded ? '▾' : '▸', + }); + + const toggle = () => { + body.classList.toggle('hidden'); + const open = !body.classList.contains('hidden'); + chevron.textContent = open ? '▾' : '▸'; + }; + + return el('div', { class: 'bg-gray-900 rounded-lg border border-gray-800 p-4' }, [ + el('div', { class: 'flex items-center flex-wrap gap-3' }, [ + el('iconify-icon', { icon: 'ant-design:folder-outlined', class: 'text-base' }), + el('span', { class: 'text-gray-200 font-mono font-semibold', text: checkpoint.name }), + el('span', { + class: 'text-[10px] px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-300', + text: tr('train_step_badge', 'Step'), + }), + el('span', { + class: 'text-xs text-gray-500', + text: `${checkpoint.file_count} ${tr('train_files_unit', 'files')}` + + ` · ${bytes(checkpoint.total_bytes)}`, + }), + el('div', { class: 'ml-auto flex gap-2' }, [ + // 「下载」= **展开文件表**(那里每行才有真下载)。不做整包下载:一个 + // checkpoint 约 600MB,其中 training_state/(优化器状态,约 400MB)只在 + // 续训时有用;拿去推理/部署只要 pretrained_model/(约 200MB)。 + // 要"一键整包"得后端加一个压 zip 的端点,本期没有。 + actionButton(tr('train_btn_download', 'Download'), + tr('train_btn_hint_download_row', + 'Expand the file list — each file downloads individually'), + () => { if (body.classList.contains('hidden')) toggle(); }), + // 「推理」= 跳到「模型推理」页签并选中本检查点。**纯前端跳转,不需要后端** —— + // 那正是"我要拿这个模型去推理"的自然动作。 + actionButton(tr('train_btn_infer', 'Inference'), + tr('train_btn_hint_infer_row', + 'Open the Inference tab with this checkpoint selected'), + () => handlers?.onInfer?.(checkpoint)), + pendingButton(tr('train_btn_delete', 'Delete'), + tr('train_btn_hint_delete', 'Destructive; no endpoint yet')), + pendingButton(tr('train_btn_deploy', 'Offline Deploy'), + tr('train_btn_hint_deploy', 'Belongs to the Deployment module')), + ]), + ]), + el('button', { + class: 'mt-3 text-xs text-gray-500 hover:text-gray-300 flex items-center gap-1.5', + on: { click: toggle }, + }, [chevron, el('span', { text: tr('train_file_details', 'File details') })]), + body, + ]); +} + +/** + * @param {HTMLElement} container + * @param {object} run 后端给的一个 run(`{name, checkpoints, …}`)—— 需要 name + * 才能拼出文件下载地址(`/runs/{name}/checkpoints/{step}/files/…`) + * @param {{onInfer?: (checkpoint: object) => void}} [handlers] + * 由 train/main.js 注入 —— 本模块不认识"页签"这回事,只负责喊一声。 + */ +export function renderCheckpoints(container, run, handlers = {}) { + clear(container); + const checkpoints = run.checkpoints || []; + + if (checkpoints.length === 0) { + container.appendChild(el('div', { + class: 'text-center text-gray-600 text-sm py-10', + text: tr('train_checkpoints_empty', 'No checkpoints under this run.'), + })); + return; + } + + // **全部默认折叠。** 曾经让最新的那个默认展开,但一进页面就被一大堆文件名 + // (每个 checkpoint 11 个文件)顶下去,反而看不到下面还有几个 checkpoint。 + // 折叠状态下每行就是一行摘要,扫一眼能看全;要看细节自己点。 + container.appendChild(el('div', { class: 'space-y-3' }, + checkpoints.map((c) => checkpointBlock(run.name, c, false, handlers)))); +} diff --git a/processor/web/static/js/train/main.js b/processor/web/static/js/train/main.js new file mode 100644 index 0000000000000000000000000000000000000000..5f525910e0517fd3de7ac57b476fc04a9f7e8a03 --- /dev/null +++ b/processor/web/static/js/train/main.js @@ -0,0 +1,296 @@ +/** 训练页入口 —— 取数据、画任务头、组织页签。 + * + * 这是 `static/js/train/` 下唯一的入口,由 `templates/train.html` 以 + * ` - - + +
diff --git a/processor/web/templates/base.html b/processor/web/templates/base.html index f2bc03f32b4821465f87596cd104fe42552f5fe8..31341a6740f51b43436f1f3a8786093fab351e1b 100644 --- a/processor/web/templates/base.html +++ b/processor/web/templates/base.html @@ -1,323 +1,364 @@ - - + + {% set boot_user = request.state.auth_user|default({}) %} {% block title %}processor{% endblock %} - - - - - + + + + + + + - - - - -
- - -
- {% block content %}{% endblock %} -
- - - - - + + function toggleUserMenu() { + const dd = document.getElementById('user-dropdown'); + const caret = document.getElementById('user-caret'); + if (!dd) return; + const isOpen = !dd.classList.contains('hidden'); + dd.classList.toggle('hidden'); + if (isOpen) { + caret.style.transform = ''; + } else { + caret.style.transform = 'rotate(180deg)'; + if (!_userMenuLoaded) loadUserInfo(); + } + } + + // Close dropdown when clicking outside + document.addEventListener('click', function(e) { + const container = document.getElementById('user-menu-container'); + const dd = document.getElementById('user-dropdown'); + if (container && dd && !container.contains(e.target)) { + if (!dd.classList.contains('hidden')) { + toggleUserMenu(); + } + } + }); + + async function handleLogout() { + try { + await fetch('/api/v1/auth/logout', { method: 'POST' }); + } catch(e) {} + window.location.href = '/login'; + } + + + diff --git a/processor/web/templates/exports.html b/processor/web/templates/exports.html index cca75ed27797f7e8d462252b426eb9e33408b3c4..6c48fa35a6819b28c1d9a8c3f36de70dcd17a76c 100644 --- a/processor/web/templates/exports.html +++ b/processor/web/templates/exports.html @@ -5,21 +5,21 @@
-

New Export

+

{{ t('new_export') }}

- +
- +

@@ -32,15 +32,15 @@ - - - - - + + + + + - +
DatasetStatusProgressCreatedActions{{ t('dataset') }}{{ t('status') }}{{ t('progress') }}{{ t('created') }}{{ t('col_actions') }}
Loading...
{{ t('loading') }}

diff --git a/processor/web/templates/index.html b/processor/web/templates/index.html index 2af79d0cca0cc12d6436f27eebd9608f74c101e5..012fa0f1d45ff18e2229704f8b6f1b4bf633b1a8 100644 --- a/processor/web/templates/index.html +++ b/processor/web/templates/index.html @@ -274,20 +274,20 @@ class="text-xs px-2.5 py-1.5 rounded-lg border border-gray-600/70 bg-gray-900/55 backdrop-blur-md text-gray-300 hover:border-blue-500 hover:text-white transition-colors flex items-center gap-1 shadow-lg" style="cursor:move;user-select:none;"> - Preview Options + {{ t('preview_options') }}