quyu666 commited on
Commit
95f5290
·
1 Parent(s): d646dd7

sync processor from GitLab 32d515b: glove display fixes, GPU acceleration, AI annotation propose-clip fix

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -0
  2. processor/CHANGELOG.md +11 -0
  3. processor/README.md +35 -104
  4. processor/README.zh-CN.md +17 -13
  5. processor/app/ai_annotation.py +31 -2
  6. processor/app/api/projects.py +9 -0
  7. processor/app/browser_preview.py +390 -2
  8. processor/app/config.py +5 -5
  9. processor/app/database.py +4 -4
  10. processor/app/lerobot_export.py +82 -4
  11. processor/app/lerobot_v21.py +140 -5
  12. processor/app/localstore.py +3 -0
  13. processor/app/main.py +20 -2
  14. processor/app/media_cache.py +1 -1
  15. processor/app/paths.py +1 -0
  16. processor/app/processing/__init__.py +201 -201
  17. processor/app/processing/black_glove/README.md +1 -1
  18. processor/app/processing/black_glove/__init__.py +1 -1
  19. processor/app/processing/black_glove/d435_hands_demo.py +31 -13
  20. processor/app/processing/black_glove/d435_tracking.py +1 -0
  21. processor/app/processing/black_glove/pose_backends.py +10 -1
  22. processor/app/processing/modules/depth_hand_3d.py +1 -1
  23. processor/app/processing/modules/rgb_world_tracking.py +1 -1
  24. processor/app/routes/auth.py +3 -3
  25. processor/app/routes/export.py +172 -18
  26. processor/app/routes/ingestion.py +140 -42
  27. processor/app/routes/video.py +303 -22
  28. processor/app/version.py +2 -2
  29. processor/deploy.py +4 -4
  30. processor/deploy.sh +0 -0
  31. processor/docs/README.md +896 -0
  32. processor/docs/en/README.md +862 -0
  33. processor/docs/images/02-home-annotated.png +3 -0
  34. processor/docs/images/02-home-guide.svg +28 -0
  35. processor/docs/images/02-home.png +3 -0
  36. processor/docs/images/README.md +26 -0
  37. processor/docs/images/annotation-editor-annotated.png +3 -0
  38. processor/docs/images/annotation-editor-guide.svg +37 -0
  39. processor/docs/images/annotation-editor.png +3 -0
  40. processor/docs/images/annotation-list-annotated.png +3 -0
  41. processor/docs/images/annotation-list-guide.svg +22 -0
  42. processor/docs/images/annotation-list.png +3 -0
  43. processor/docs/images/approved-list-annotated.png +3 -0
  44. processor/docs/images/approved-list-guide.svg +31 -0
  45. processor/docs/images/approved-list.png +3 -0
  46. processor/docs/images/project-new-annotated.png +3 -0
  47. processor/docs/images/project-new-guide.svg +31 -0
  48. processor/docs/images/project-new.png +3 -0
  49. processor/docs/images/project-workflow-select-annotated.png +3 -0
  50. processor/docs/images/project-workflow-select-guide.svg +28 -0
.gitattributes CHANGED
@@ -41,3 +41,6 @@ collector/**/*.jpg filter=lfs diff=lfs merge=lfs -text
41
  collector/**/*.png filter=lfs diff=lfs merge=lfs -text
42
  collector/**/*.bmp filter=lfs diff=lfs merge=lfs -text
43
  collector/**/*.task filter=lfs diff=lfs merge=lfs -text
 
 
 
 
41
  collector/**/*.png filter=lfs diff=lfs merge=lfs -text
42
  collector/**/*.bmp filter=lfs diff=lfs merge=lfs -text
43
  collector/**/*.task filter=lfs diff=lfs merge=lfs -text
44
+
45
+ # processor 文档截图走 LFS(HF 拒绝非 LFS 二进制)
46
+ processor/**/*.png filter=lfs diff=lfs merge=lfs -text
processor/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 更新日志
2
+
3
+ ## 1.7.0(2026-09-08)
4
+
5
+ - 文档:中英文使用文档重构,补充各页面带标注截图与引导图
6
+ - 部署:uvicorn + worker 开机自启(systemd),任务队列不再因 worker 未启动而积压
7
+ - 其他:.gitignore 增加大目录防误传规则
8
+
9
+ ## 1.6.9(2026-09-07)
10
+
11
+ - 深度预览预热扩展、导出链路优化(本地构建/指纹复用)、下载 gzip 修复
processor/README.md CHANGED
@@ -1,13 +1,15 @@
1
- # Data Acquisition
2
 
3
  [English](README.md) | [中文](README.zh-CN.md)
4
 
5
- > Documentation: 2026-09-03 · Backend: `1.6.8` · Web frontend: `1.5.6`
6
 
7
- Data Acquisition is EgoData's service for data capture, processing, review, and export. It is
8
  designed for embodied AI and robot manipulation data. The system consists of a FastAPI backend,
9
  an asynchronous Worker, Web review pages, and Workflow Studio.
10
 
 
 
11
  ```text
12
  Capture upload → Project matching → Workflow processing → AI/Human review → LeRobot/HDF5 export
13
  ```
@@ -16,7 +18,7 @@ Capture upload → Project matching → Workflow processing → AI/Human review
16
 
17
  - [System overview](#1-system-overview)
18
  - [Installation and startup](#7-installation-and-startup)
19
- - [Code layout and repository tree](#2-code-layout-and-repository-tree)
20
  - [Project data structure](#3-project-data-structure)
21
  - [Video data specification](#4-video-data-specification)
22
  - [Workflow modules](#5-workflow-modules)
@@ -54,66 +56,30 @@ Web review pages
54
  LeRobot v2.1 / v3.0 or HDF5
55
  ```
56
 
57
- ## 2. Code layout and repository tree
 
 
58
 
59
- <details>
60
- <summary>Click to expand: source tree and runtime directories</summary>
61
 
62
- The current maintained source tree is shown below. Runtime data, caches, virtual environments,
63
- local backups, test exports, and downloaded model weights are intentionally omitted.
64
 
65
  ```text
66
- Data Acquisition/
67
- ├── app/
68
- │ ├── api/ # Project, workflow, user, and Worker APIs
69
- │ ├── processing/
70
- │ │ ├── black_glove/ # Black-glove detection, tracking, and pose backend
71
- │ │ └── modules/ # RGB, RGB-D, stereo, hand, glove, review, and export nodes
72
- │ ├── prompts/ # AI annotation prompts and vocabularies
73
- │ ├── routes/ # Page, session, video, annotation, and export routes
74
- │ ├── ai_annotation.py # Local/API VLM annotation pipeline
75
- │ ├── browser_preview.py # Browser preview metadata and media preparation
76
- │ ├── export_engine.py # Export job orchestration
77
- │ ├── hdf5_export.py # HDF5 dataset writer
78
- │ ├── lerobot_export.py # LeRobot v3.0 exporter and metadata writer
79
- │ ├── lerobot_v21.py # LeRobot v2.1 normalization support
80
- │ ├── project_dataset.py # Canonical project and Episode operations
81
- │ ├── storage.py / remote_storage.py # Local and SFTP-backed storage
82
- │ ├── workflow_*.py # Workflow schema, binding, and dispatch
83
- │ ├── models.py / database.py # Runtime state models and persistence
84
- │ └── main.py # FastAPI application entry point
85
- ├── worker/
86
- │ ├── runner.py # Job claim, heartbeat, execution, and callback loop
87
- │ ├── client.py # Backend Worker API client
88
- │ └── README.md # Worker-specific notes
89
  ├── web/
90
- │ ├── templates/ # Server-rendered application pages
91
- │ ├── static/
92
- │ │ ├── js/ # Player, depth preview, overlays, review, and UI JS
93
- │ │ └── workflow-studio/ # Built Workflow Studio assets served by FastAPI
94
- │ └── workflow-studio/
95
- │ └── src/
96
- │ ├── api/ # Frontend API clients
97
- │ ├── components/ # Canvas, nodes, palette, drawer, and settings UI
98
- │ ├── store/ # Workflow and UI state stores
99
- │ └── App.tsx # Workflow Studio application root
100
- ├── scripts/ # Setup, startup, migration, and maintenance scripts
101
- │ ├── run_backend_linux.sh # Linux FastAPI startup
102
- │ ├── run_worker_linux.sh # Linux Worker startup
103
- │ ├── hot_reload_backend.py # Development backend reload supervisor
104
- │ ├── hot_reload_worker.py # Development Worker reload supervisor
105
- │ ├── migrate_*.py / repack_*.py # Dataset migration and repair tools
106
- │ └── systemd/ # Linux service templates
107
- ├── tests/
108
- │ └── test_depth_codec.py # 12-bit depth codec tests
109
- ├── requirements*.txt # Python dependency manifests
110
- ├── deploy.py # Cross-platform deployment entry point
111
- ├── deploy.sh / deploy.bat # Linux and Windows deployment entry points
112
- ├── Dockerfile / .dockerignore # Container build files
113
- ├── .env.example # Safe configuration template
114
- ├── .gitignore # Runtime data, secrets, models, and cache exclusions
115
- ├── README.md # English documentation
116
- └── README.zh-CN.md # Chinese documentation
117
  ```
118
 
119
  Runtime data is stored under `STORAGE_DIR` by default:
@@ -122,9 +88,7 @@ Runtime data is stored under `STORAGE_DIR` by default:
122
  data/
123
  ├── sessions/ # Project datasets
124
  ├── state/ # System state, queues, run records, and export jobs
125
- ├── logs/ # Runtime and archive-sync logs
126
- ├── .meta-history/ # Metadata migration rollback snapshots
127
- └── tmp/ # Upload, AI, and Worker temporary files
128
  ```
129
 
130
  Runtime data, model weights, archives, credentials, and virtual environments must not be
@@ -132,7 +96,6 @@ committed to Git. Dependency manifests such as `requirements*.txt` and `package.
132
  kept in the repository.
133
 
134
  </details>
135
-
136
  ## 3. Project data structure
137
 
138
  <details>
@@ -211,7 +174,7 @@ RGB and is never duplicated alongside the RGB stream.
211
  New metric-depth videos use:
212
 
213
  ```text
214
- HEVC (H.265) in MP4
215
  ```
216
 
217
  The depth video stores 12-bit logarithmic depth codes, not pseudo-color images:
@@ -224,11 +187,6 @@ The corresponding feature in `meta/info.json` is marked with `video.is_depth_map
224
  depth encoding parameters. Readers dequantize the codes back to millimeters. The original depth
225
  video is preserved during processing and export.
226
 
227
- Depth is never exported as a JET/RGB pseudo-color video. Exported depth remains a single-channel
228
- `gray12le` video containing the original 12-bit logarithmic codes. The frontend must use the
229
- `codes_to_heatmap_bgr()` rule for decoded depth codes; it must not quantize decoded codes a second
230
- time as if they were millimeter values.
231
-
232
  ### Frontend depth preview
233
 
234
  Pseudo-color depth is used only for browser preview and is never written back to the dataset:
@@ -388,17 +346,6 @@ RGB video in the browser; a second skeleton video is not saved. System-level run
388
  under `data/state/runs/`, and temporary export products under `data/state/exports/`; neither is
389
  part of the project dataset directory.
390
 
391
- Glove tactile data is kept as fixed-size numeric arrays in the Parquet data, for example
392
- `observation.tactile.left` and `observation.tactile.right` with shape `[256]`. The current
393
- LeRobot training export does not generate or retain tactile MP4 files. Tactile visualization is a
394
- frontend preview concern; it is not required as a video feature by the LeRobot export.
395
-
396
- The export module supports LeRobot v2.1 and v3.0. A v3.0 export is directly readable by the
397
- current official LeRobot reader. A v2.1 export follows the official v2.1 layout and must be
398
- converted with the official `convert_dataset_v21_to_v30` tool before using a v3.0 reader.
399
- Batch export creates one dataset directory per selected Episode or project selection; each output
400
- contains only the canonical `data/`, `meta/`, and `videos/` directories.
401
-
402
  The upload archive lifecycle is: receive → extract → normalize → write to project → validate →
403
  update status. Temporary upload files and extraction directories are cleaned only after extraction,
404
  atomic commit, and validation all succeed. On failure, staging content is retained for retry and
@@ -411,7 +358,7 @@ diagnosis.
411
  ### Linux
412
 
413
  ```bash
414
- cd "Data Acquisition"
415
  cp .env.example .env
416
  # Edit .env and set API_KEY, WORKER_API_KEY, JWT_SECRET, and storage settings.
417
  chmod +x deploy.sh
@@ -432,14 +379,14 @@ model, and endpoint in the `AI Annotation` settings in Workflow Studio.
432
  ### Windows
433
 
434
  ```bat
435
- cd /d "Data Acquisition"
436
  copy .env.example .env && deploy.bat
437
  ```
438
 
439
  ### Start the backend manually
440
 
441
  ```bash
442
- cd "Data Acquisition"
443
  source .venv-linux/bin/activate
444
  export PYTHONPATH="$PWD"
445
  ./scripts/run_backend_linux.sh
@@ -454,7 +401,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000
454
  ### Start the Worker
455
 
456
  ```bash
457
- cd "Data Acquisition"
458
  export EGODATA_SERVER_URL=http://127.0.0.1:8000
459
  export EGODATA_WORKER_API_KEY='same Worker API key as the server'
460
  ./scripts/run_worker_linux.sh
@@ -466,7 +413,7 @@ independent and must not be copied between operating systems.
466
  ### Workflow Studio
467
 
468
  ```bash
469
- cd "Data Acquisition/web/workflow-studio"
470
  npm ci
471
  npm run dev
472
  ```
@@ -484,7 +431,7 @@ The build output is written to `web/static/workflow-studio/` and served by FastA
484
  <details>
485
  <summary>Click to expand: environment variables and security configuration</summary>
486
 
487
- Configuration is stored in `Data Acquisition/.env` or environment variables and must not be
488
  committed:
489
 
490
  | Setting | Purpose |
@@ -505,22 +452,6 @@ credentials in logs, workflow JSON, frontend code, or documentation.
505
 
506
  </details>
507
 
508
- ### GitHub publication checklist
509
-
510
- Before publishing this directory:
511
-
512
- ```bash
513
- git status --short --ignored
514
- git check-ignore -v .env data .backups .venv-linux models
515
- git ls-files | rg -i '(^|/)(\.env|.*secret.*|.*token.*|.*credential.*|.*password.*|.*\.pem|.*\.key)$'
516
- ```
517
-
518
- The repository excludes runtime datasets, temporary files, local state, backups, virtual
519
- environments, archives, and secrets through `.gitignore`. Keep `.env` local and commit only
520
- `.env.example`. Do not use `git add -f` for `data/`, `.backups/`, model weights, or generated
521
- exports. If a real credential was ever placed in a tracked file, rotate it before publishing;
522
- removing it from the working tree alone does not remove it from Git history.
523
-
524
  ## 9. API
525
 
526
  <details>
@@ -559,7 +490,7 @@ The running FastAPI OpenAPI document is the authoritative API contract.
559
  Backend and Worker checks:
560
 
561
  ```bash
562
- cd "Data Acquisition"
563
  python -m compileall app worker
564
  python deploy.py --check-only
565
  PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q tests/test_depth_codec.py
@@ -568,7 +499,7 @@ PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q tests/test_depth_codec.py
568
  Frontend check:
569
 
570
  ```bash
571
- cd "Data Acquisition/web/workflow-studio"
572
  npm run build
573
  ```
574
 
 
1
+ # processor
2
 
3
  [English](README.md) | [中文](README.zh-CN.md)
4
 
5
+ > Documentation: 2026-09-02 · Backend: `1.6.8` · Web frontend: `1.5.6`
6
 
7
+ processor is EgoData's service for data capture, processing, review, and export. It is
8
  designed for embodied AI and robot manipulation data. The system consists of a FastAPI backend,
9
  an asynchronous Worker, Web review pages, and Workflow Studio.
10
 
11
+ > Beginners: start with the [illustrated user guide](docs/en/README.md).
12
+
13
  ```text
14
  Capture upload → Project matching → Workflow processing → AI/Human review → LeRobot/HDF5 export
15
  ```
 
18
 
19
  - [System overview](#1-system-overview)
20
  - [Installation and startup](#7-installation-and-startup)
21
+ - [Code layout](#2-code-layout)
22
  - [Project data structure](#3-project-data-structure)
23
  - [Video data specification](#4-video-data-specification)
24
  - [Workflow modules](#5-workflow-modules)
 
56
  LeRobot v2.1 / v3.0 or HDF5
57
  ```
58
 
59
+ The local `processor/` directory is the only source of frontend and backend code. The
60
+ remote server provides data storage or SFTP access only; its deprecated frontend and backend
61
+ code must not be used as the project source.
62
 
63
+ ## 2. Code layout
 
64
 
65
+ <details>
66
+ <summary>Click to expand: code layout</summary>
67
 
68
  ```text
69
+ processor/
70
+ ├── app/ # FastAPI, storage, workflows, processing, and export logic
71
+ ├── worker/ # Asynchronous processing Worker
72
+ ├── scripts/ # Deployment, startup, checks, and maintenance scripts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  ├── web/
74
+ │ ├── templates/ # Web page templates
75
+ │ ├── static/ # CSS, JavaScript, and frontend build artifacts
76
+ │ └── workflow-studio/ # React + Vite workflow editor source
77
+ ├── tests/ # Automated tests
78
+ ├── requirements*.txt # Python dependency manifests
79
+ ├── deploy.py # Cross-platform deployment entry point
80
+ ├── deploy.sh # Linux deployment entry point
81
+ ├── deploy.bat # Windows deployment entry point
82
+ └── .env.example # Configuration template
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  ```
84
 
85
  Runtime data is stored under `STORAGE_DIR` by default:
 
88
  data/
89
  ├── sessions/ # Project datasets
90
  ├── state/ # System state, queues, run records, and export jobs
91
+ └── tmp/ # Upload and Worker temporary files
 
 
92
  ```
93
 
94
  Runtime data, model weights, archives, credentials, and virtual environments must not be
 
96
  kept in the repository.
97
 
98
  </details>
 
99
  ## 3. Project data structure
100
 
101
  <details>
 
174
  New metric-depth videos use:
175
 
176
  ```text
177
+ HEVC (H.265) in MP4 / hvc1 / gray12le / qp=6 / range=full
178
  ```
179
 
180
  The depth video stores 12-bit logarithmic depth codes, not pseudo-color images:
 
187
  depth encoding parameters. Readers dequantize the codes back to millimeters. The original depth
188
  video is preserved during processing and export.
189
 
 
 
 
 
 
190
  ### Frontend depth preview
191
 
192
  Pseudo-color depth is used only for browser preview and is never written back to the dataset:
 
346
  under `data/state/runs/`, and temporary export products under `data/state/exports/`; neither is
347
  part of the project dataset directory.
348
 
 
 
 
 
 
 
 
 
 
 
 
349
  The upload archive lifecycle is: receive → extract → normalize → write to project → validate →
350
  update status. Temporary upload files and extraction directories are cleaned only after extraction,
351
  atomic commit, and validation all succeed. On failure, staging content is retained for retry and
 
358
  ### Linux
359
 
360
  ```bash
361
+ cd "processor"
362
  cp .env.example .env
363
  # Edit .env and set API_KEY, WORKER_API_KEY, JWT_SECRET, and storage settings.
364
  chmod +x deploy.sh
 
379
  ### Windows
380
 
381
  ```bat
382
+ cd /d "processor"
383
  copy .env.example .env && deploy.bat
384
  ```
385
 
386
  ### Start the backend manually
387
 
388
  ```bash
389
+ cd "processor"
390
  source .venv-linux/bin/activate
391
  export PYTHONPATH="$PWD"
392
  ./scripts/run_backend_linux.sh
 
401
  ### Start the Worker
402
 
403
  ```bash
404
+ cd "processor"
405
  export EGODATA_SERVER_URL=http://127.0.0.1:8000
406
  export EGODATA_WORKER_API_KEY='same Worker API key as the server'
407
  ./scripts/run_worker_linux.sh
 
413
  ### Workflow Studio
414
 
415
  ```bash
416
+ cd "processor/web/workflow-studio"
417
  npm ci
418
  npm run dev
419
  ```
 
431
  <details>
432
  <summary>Click to expand: environment variables and security configuration</summary>
433
 
434
+ Configuration is stored in `processor/.env` or environment variables and must not be
435
  committed:
436
 
437
  | Setting | Purpose |
 
452
 
453
  </details>
454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  ## 9. API
456
 
457
  <details>
 
490
  Backend and Worker checks:
491
 
492
  ```bash
493
+ cd "processor"
494
  python -m compileall app worker
495
  python deploy.py --check-only
496
  PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q tests/test_depth_codec.py
 
499
  Frontend check:
500
 
501
  ```bash
502
+ cd "processor/web/workflow-studio"
503
  npm run build
504
  ```
505
 
processor/README.zh-CN.md CHANGED
@@ -1,12 +1,14 @@
1
- # Data Acquisition
2
 
3
  [中文](README.zh-CN.md) | [English](README.md)
4
 
5
  > 文档版本:2026-09-02 · 后端版本:`1.6.8` · WEB 前端版本:`1.5.6`
6
 
7
- Data Acquisition 是 EgoData 的数据采集、处理、审核和导出服务,面向具身智能与机器人
8
  操作数据。系统由 FastAPI 后端、异步 Worker、Web 审核页面和 Workflow Studio 组成。
9
 
 
 
10
  ```text
11
  采集端上传 → 项目匹配 → 工作流处理 → AI/人工审核 → LeRobot/HDF5 导出
12
  ```
@@ -53,13 +55,16 @@ Web 审核页面
53
  LeRobot v2.1 / v3.0 或 HDF5
54
  ```
55
 
 
 
 
56
  ## 2. 代码目录
57
 
58
  <details>
59
  <summary>点击展开:代码目录</summary>
60
 
61
  ```text
62
- Data Acquisition/
63
  ├── app/ # FastAPI、存储、工作流、处理和导出逻辑
64
  ├── worker/ # 异步处理 Worker
65
  ├── scripts/ # 部署、启动、检查和数据维护脚本
@@ -88,7 +93,6 @@ data/
88
  `package.json` 等依赖清单应保留在仓库中。
89
 
90
  </details>
91
-
92
  ## 3. 项目数据结构
93
 
94
  <details>
@@ -165,7 +169,7 @@ RGB 和 Depth 始终作为独立数据流保存。纯 Depth 流不会被当作 R
165
  新的真实深度视频统一使用:
166
 
167
  ```text
168
- HEVC (H.265) in MP4
169
  ```
170
 
171
  深度视频保存的是 12-bit 对数深度码,不是伪彩色图像:
@@ -342,7 +346,7 @@ meta/episodes/chunk-000/episode_000000.parquet
342
  ### Linux
343
 
344
  ```bash
345
- cd "Data Acquisition"
346
  cp .env.example .env
347
  # 编辑 .env,设置 API_KEY、WORKER_API_KEY、JWT_SECRET 和存储配置。
348
  chmod +x deploy.sh
@@ -363,14 +367,14 @@ python deploy.py --no-services # 前台运行,不安装自启动服务。
363
  ### Windows
364
 
365
  ```bat
366
- cd /d "Data Acquisition"
367
  copy .env.example .env && deploy.bat
368
  ```
369
 
370
  ### 手动启动后端
371
 
372
  ```bash
373
- cd "Data Acquisition"
374
  source .venv-linux/bin/activate
375
  export PYTHONPATH="$PWD"
376
  ./scripts/run_backend_linux.sh
@@ -385,7 +389,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000
385
  ### 启动 Worker
386
 
387
  ```bash
388
- cd "Data Acquisition"
389
  export EGODATA_SERVER_URL=http://127.0.0.1:8000
390
  export EGODATA_WORKER_API_KEY='与服务端一致的 Worker API Key'
391
  ./scripts/run_worker_linux.sh
@@ -397,7 +401,7 @@ Windows 使用 `scripts/run_worker_windows.ps1`。Linux 和 Windows 的虚拟环
397
  ### Workflow Studio
398
 
399
  ```bash
400
- cd "Data Acquisition/web/workflow-studio"
401
  npm ci
402
  npm run dev
403
  ```
@@ -415,7 +419,7 @@ npm run build
415
  <details>
416
  <summary>点击展开:环境变量和安全配置</summary>
417
 
418
- 配置写入 `Data Acquisition/.env` 或环境变量,不提交到仓库:
419
 
420
  | 配置 | 作用 |
421
  | --- | --- |
@@ -472,7 +476,7 @@ GET /openapi.json
472
  后端和 Worker 基础检查:
473
 
474
  ```bash
475
- cd "Data Acquisition"
476
  python -m compileall app worker
477
  python deploy.py --check-only
478
  PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q tests/test_depth_codec.py
@@ -481,7 +485,7 @@ PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q tests/test_depth_codec.py
481
  前端检查:
482
 
483
  ```bash
484
- cd "Data Acquisition/web/workflow-studio"
485
  npm run build
486
  ```
487
 
 
1
+ # processor
2
 
3
  [中文](README.zh-CN.md) | [English](README.md)
4
 
5
  > 文档版本:2026-09-02 · 后端版本:`1.6.8` · WEB 前端版本:`1.5.6`
6
 
7
+ processor 是 EgoData 的数据采集、处理、审核和导出服务,面向具身智能与机器人
8
  操作数据。系统由 FastAPI 后端、异步 Worker、Web 审核页面和 Workflow Studio 组成。
9
 
10
+ > 项目文档:[项目文档介绍](docs/README.md)。
11
+
12
  ```text
13
  采集端上传 → 项目匹配 → 工作流处理 → AI/人工审核 → LeRobot/HDF5 导出
14
  ```
 
55
  LeRobot v2.1 / v3.0 或 HDF5
56
  ```
57
 
58
+ 本机 `processor/` 目录是前端和后端代码的唯一来源。远端服务器只提供数据目录
59
+ 或 SFTP 存储,不作为本项目的前端、后端代码来源;服务器中的旧代码已废弃。
60
+
61
  ## 2. 代码目录
62
 
63
  <details>
64
  <summary>点击展开:代码目录</summary>
65
 
66
  ```text
67
+ processor/
68
  ├── app/ # FastAPI、存储、工作流、处理和导出逻辑
69
  ├── worker/ # 异步处理 Worker
70
  ├── scripts/ # 部署、启动、检查和数据维护脚本
 
93
  `package.json` 等依赖清单应保留在仓库中。
94
 
95
  </details>
 
96
  ## 3. 项目数据结构
97
 
98
  <details>
 
169
  新的真实深度视频统一使用:
170
 
171
  ```text
172
+ HEVC (H.265) in MP4 / hvc1 / gray12le / qp=6 / range=full
173
  ```
174
 
175
  深度视频保存的是 12-bit 对数深度码,不是伪彩色图像:
 
346
  ### Linux
347
 
348
  ```bash
349
+ cd "processor"
350
  cp .env.example .env
351
  # 编辑 .env,设置 API_KEY、WORKER_API_KEY、JWT_SECRET 和存储配置。
352
  chmod +x deploy.sh
 
367
  ### Windows
368
 
369
  ```bat
370
+ cd /d "processor"
371
  copy .env.example .env && deploy.bat
372
  ```
373
 
374
  ### 手动启动后端
375
 
376
  ```bash
377
+ cd "processor"
378
  source .venv-linux/bin/activate
379
  export PYTHONPATH="$PWD"
380
  ./scripts/run_backend_linux.sh
 
389
  ### 启动 Worker
390
 
391
  ```bash
392
+ cd "processor"
393
  export EGODATA_SERVER_URL=http://127.0.0.1:8000
394
  export EGODATA_WORKER_API_KEY='与服务端一致的 Worker API Key'
395
  ./scripts/run_worker_linux.sh
 
401
  ### Workflow Studio
402
 
403
  ```bash
404
+ cd "processor/web/workflow-studio"
405
  npm ci
406
  npm run dev
407
  ```
 
419
  <details>
420
  <summary>点击展开:环境变量和安全配置</summary>
421
 
422
+ 配置写入 `processor/.env` 或环境变量,不提交到仓库:
423
 
424
  | 配置 | 作用 |
425
  | --- | --- |
 
476
  后端和 Worker 基础检查:
477
 
478
  ```bash
479
+ cd "processor"
480
  python -m compileall app worker
481
  python deploy.py --check-only
482
  PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q tests/test_depth_codec.py
 
485
  前端检查:
486
 
487
  ```bash
488
+ cd "processor/web/workflow-studio"
489
  npm run build
490
  ```
491
 
processor/app/ai_annotation.py CHANGED
@@ -2265,6 +2265,33 @@ async def _upload_video_file(client: httpx.AsyncClient, upload_base: str,
2265
  return None
2266
 
2267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2268
  def _inline_video_data_url(clip: Path, *,
2269
  max_bytes: int = API_INLINE_VIDEO_MAX_BYTES,
2270
  label: str = "API VLM") -> str:
@@ -2414,7 +2441,7 @@ async def _vlm_propose_api(video_path: Path, fps: float, total_frames: int,
2414
  raise VLMAnnotationError("configuration_error", err)
2415
  whole = {"start_frame_index": 0,
2416
  "end_frame_index": max(0, total_frames - 1)}
2417
- clip = _slice_segment_clip(video_path, fps, whole, None, None, tmp_dir)
2418
  if clip is None:
2419
  raise VLMAnnotationError("video_slice_failed", "API VLM 输入视频切片失败")
2420
  try:
@@ -2425,6 +2452,8 @@ async def _vlm_propose_api(video_path: Path, fps: float, total_frames: int,
2425
  except ValueError as exc:
2426
  raise VLMAnnotationError(
2427
  "api_video_too_large", str(exc)) from exc
 
 
2428
  prompts = _prompts()
2429
  template = prompts.get("plan_prompt_zh" if lang == "zh" else "plan_prompt_en")
2430
  vocab = prompts.get("vocab_zh" if lang == "zh" else "vocab_en", "")
@@ -2437,7 +2466,7 @@ async def _vlm_propose_api(video_path: Path, fps: float, total_frames: int,
2437
  "\n\n输出要求:最终答案必须放在 assistant 的 content 字段中,"
2438
  "只输出完整 JSON,不要把答案只放在 reasoning 或思考内容中。")
2439
  content = [
2440
- _api_video_content(vlm_cfg, video_ref, fps),
2441
  {"type": "text", "text": prompt},
2442
  ]
2443
  try:
 
2265
  return None
2266
 
2267
 
2268
+ def _slice_propose_clip(video_path: Path, out_dir: Path) -> Path | None:
2269
+ """整片规划专用切片:降采样到 ≤640px / 5fps 低码率。
2270
+
2271
+ 长批次(如 305s)原分辨率整片 base64 内联会打出 50MiB+ 的 JSON,
2272
+ 租用网关直接把连接掐断(ReadError)。规划阶段只需要看清动作边界,
2273
+ 5fps/640px 足够;时长不变,start_s/end_s 仍映射源视频时间。
2274
+ """
2275
+ import subprocess
2276
+
2277
+ out = out_dir / "propose_clip.mp4"
2278
+ cmd = ["ffmpeg", "-y", "-loglevel", "error",
2279
+ "-i", str(video_path),
2280
+ "-vf", "scale='min(640,iw)':-2,fps=5",
2281
+ "-c:v", "libx264", "-crf", "30", "-preset", "veryfast",
2282
+ "-pix_fmt", "yuv420p",
2283
+ "-movflags", "+faststart", "-an", str(out)]
2284
+ try:
2285
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
2286
+ except (OSError, subprocess.TimeoutExpired) as exc:
2287
+ print(f"[ai_annotation] propose clip error: {exc}")
2288
+ return None
2289
+ if r.returncode != 0 or not out.is_file() or out.stat().st_size < 1024:
2290
+ print(f"[ai_annotation] propose clip failed: {r.stderr[:300]}")
2291
+ return None
2292
+ return out
2293
+
2294
+
2295
  def _inline_video_data_url(clip: Path, *,
2296
  max_bytes: int = API_INLINE_VIDEO_MAX_BYTES,
2297
  label: str = "API VLM") -> str:
 
2441
  raise VLMAnnotationError("configuration_error", err)
2442
  whole = {"start_frame_index": 0,
2443
  "end_frame_index": max(0, total_frames - 1)}
2444
+ clip = _slice_propose_clip(video_path, tmp_dir)
2445
  if clip is None:
2446
  raise VLMAnnotationError("video_slice_failed", "API VLM 输入视频切片失败")
2447
  try:
 
2452
  except ValueError as exc:
2453
  raise VLMAnnotationError(
2454
  "api_video_too_large", str(exc)) from exc
2455
+ # propose 剪辑统一 5fps,fps 提示与剪辑实际一致(时长不变,时间轴对齐)
2456
+ propose_fps = 5.0
2457
  prompts = _prompts()
2458
  template = prompts.get("plan_prompt_zh" if lang == "zh" else "plan_prompt_en")
2459
  vocab = prompts.get("vocab_zh" if lang == "zh" else "vocab_en", "")
 
2466
  "\n\n输出要求:最终答案必须放在 assistant 的 content 字段中,"
2467
  "只输出完整 JSON,不要把答案只放在 reasoning 或思考内容中。")
2468
  content = [
2469
+ _api_video_content(vlm_cfg, video_ref, propose_fps),
2470
  {"type": "text", "text": prompt},
2471
  ]
2472
  try:
processor/app/api/projects.py CHANGED
@@ -576,6 +576,15 @@ def get_hierarchy(
576
  if s in n["project"]["name"].lower()
577
  or any(s in e["name"].lower() or s in e["id"].lower() for e in n["episodes"])]
578
 
 
 
 
 
 
 
 
 
 
579
  return {"projects": tree, "total": sum(len(n["episodes"]) for n in tree)}
580
 
581
 
 
576
  if s in n["project"]["name"].lower()
577
  or any(s in e["name"].lower() or s in e["id"].lower() for e in n["episodes"])]
578
 
579
+ # 预热深度预览:列表可见批次在用户点开前后台生成(尽力而为)
580
+ try:
581
+ from app.routes.ingestion import _trigger_depth_prewarm
582
+
583
+ _trigger_depth_prewarm(
584
+ [e["id"] for node in tree for e in node.get("episodes", [])])
585
+ except Exception:
586
+ pass
587
+
588
  return {"projects": tree, "total": sum(len(n["episodes"]) for n in tree)}
589
 
590
 
processor/app/browser_preview.py CHANGED
@@ -13,10 +13,12 @@ the configured storage directory.
13
  from __future__ import annotations
14
 
15
  import hashlib
 
16
  import os
17
  import shutil
18
  import subprocess
19
  import threading
 
20
  from pathlib import Path
21
 
22
  from app.config import settings
@@ -25,6 +27,9 @@ from app.config import settings
25
  _LOCKS: dict[str, threading.Lock] = {}
26
  _LOCKS_GUARD = threading.Lock()
27
 
 
 
 
28
 
29
  def _cache_root() -> Path:
30
  root = settings.upload_staging_root / "browser-preview"
@@ -43,6 +48,296 @@ def _lock_for(key: str) -> threading.Lock:
43
  return _LOCKS.setdefault(key, threading.Lock())
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  def _is_h264_yuv420(source: Path) -> bool:
47
  """Avoid re-encoding an already browser-compatible source."""
48
  ffprobe = shutil.which("ffprobe")
@@ -61,17 +356,98 @@ def _is_h264_yuv420(source: Path) -> bool:
61
  return False
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def ensure_h264_preview(source: Path) -> Path:
65
  """Return an H.264/yuv420p MP4 suitable for HTML5 playback.
66
 
 
 
 
 
 
 
67
  Callers should run this function via ``asyncio.to_thread`` so a cold
68
  transcode never blocks FastAPI's event loop.
69
  """
70
  source = Path(source)
71
  if not source.is_file():
72
  raise FileNotFoundError(f"Video source not found: {source}")
73
- if _is_h264_yuv420(source):
74
- return source
75
 
76
  ffmpeg = shutil.which("ffmpeg")
77
  if not ffmpeg:
@@ -81,10 +457,22 @@ def ensure_h264_preview(source: Path) -> Path:
81
  destination = _cache_root() / f"{key}.mp4"
82
  if destination.is_file() and destination.stat().st_size > 0:
83
  return destination
 
 
84
 
85
  with _lock_for(key):
86
  if destination.is_file() and destination.stat().st_size > 0:
87
  return destination
 
 
 
 
 
 
 
 
 
 
88
 
89
  # Keep the final .mp4 suffix so ffmpeg can infer the output muxer.
90
  temporary = destination.with_name(
 
13
  from __future__ import annotations
14
 
15
  import hashlib
16
+ import json
17
  import os
18
  import shutil
19
  import subprocess
20
  import threading
21
+ import time
22
  from pathlib import Path
23
 
24
  from app.config import settings
 
27
  _LOCKS: dict[str, threading.Lock] = {}
28
  _LOCKS_GUARD = threading.Lock()
29
 
30
+ _FASTSTART_CACHE: dict[str, bool] = {}
31
+ _FASTSTART_CACHE_LOCK = threading.Lock()
32
+
33
 
34
  def _cache_root() -> Path:
35
  root = settings.upload_staging_root / "browser-preview"
 
48
  return _LOCKS.setdefault(key, threading.Lock())
49
 
50
 
51
+ # ── Local source mirror (depth streams) ──────────────────────────────
52
+ # Depth videos live on the SSHFS mount, where random reads for seek/decode
53
+ # cost seconds. A bit-identical local copy turns every later decode into a
54
+ # local-disk read. This is a disposable transport cache of the SOURCE file
55
+ # (never a derived/colorized asset); the authoritative stream stays in the
56
+ # configured storage directory.
57
+
58
+ _MIRROR_MAX_BYTES = 8 * 1024 ** 3
59
+ _MIRROR_INFLIGHT: set[str] = set()
60
+ _MIRROR_INFLIGHT_GUARD = threading.Lock()
61
+
62
+
63
+ def _mirror_root() -> Path:
64
+ root = settings.upload_staging_root / "depth-mirror"
65
+ root.mkdir(parents=True, exist_ok=True)
66
+ return root
67
+
68
+
69
+ def _prune_mirror(keep: Path) -> None:
70
+ """Remove oldest mirror files beyond the byte budget (LRU by mtime)."""
71
+ try:
72
+ files = [p for p in _mirror_root().iterdir()
73
+ if p.is_file() and p.suffix == ".mp4"]
74
+ except OSError:
75
+ return
76
+ total = sum(p.stat().st_size for p in files if p != keep) + (
77
+ keep.stat().st_size if keep.is_file() else 0)
78
+ if total <= _MIRROR_MAX_BYTES:
79
+ return
80
+ for path in sorted(files, key=lambda p: p.stat().st_mtime_ns):
81
+ if path == keep or total <= _MIRROR_MAX_BYTES:
82
+ continue
83
+ try:
84
+ total -= path.stat().st_size
85
+ path.unlink(missing_ok=True)
86
+ except OSError:
87
+ pass
88
+
89
+
90
+ def _copy_mirror(source: Path, destination: Path, key: str) -> None:
91
+ """Background copy to a temp file, then atomic rename."""
92
+ temporary = destination.with_suffix(".part")
93
+ temporary.unlink(missing_ok=True)
94
+ try:
95
+ shutil.copy2(source, temporary)
96
+ if not temporary.is_file() or temporary.stat().st_size <= 0:
97
+ raise RuntimeError("mirror copy produced an empty file")
98
+ os.replace(temporary, destination)
99
+ _prune_mirror(destination)
100
+ except (OSError, RuntimeError):
101
+ temporary.unlink(missing_ok=True)
102
+ finally:
103
+ with _MIRROR_INFLIGHT_GUARD:
104
+ _MIRROR_INFLIGHT.discard(key)
105
+
106
+
107
+ def _write_turbo_cube(path: Path) -> None:
108
+ """Write the static 4096-entry 1D Turbo cube used by the transcode.
109
+
110
+ 4096 entries keep the range mapping at 12-bit precision: a 256-entry LUT
111
+ evaluated after an 8-bit intermediate caused visible banding (only ~58
112
+ distinct color levels survived the chain instead of ~173).
113
+ """
114
+ if path.is_file():
115
+ return
116
+ from app.lerobot_v21 import turbo_lut
117
+
118
+ lut = turbo_lut(4096)
119
+ lines = ["TITLE Turbo", "LUT_1D_SIZE 4096", "",
120
+ "DOMAIN_MIN 0 0 0", "DOMAIN_MAX 1 1 1", ""]
121
+ for r, g, b in lut:
122
+ lines.append(f"{r / 255.0:.6f} {g / 255.0:.6f} {b / 255.0:.6f}")
123
+ path.write_text("\n".join(lines) + "\n", encoding="ascii")
124
+
125
+
126
+ def _depth_stats_path(source: Path) -> Path:
127
+ key = _cache_key(source)
128
+ return _cache_root() / f"depth-{key}.stats.json"
129
+
130
+
131
+ def read_depth_stats(source: Path) -> dict | None:
132
+ """Disk-persisted q01/q99 bounds for ``source`` (same fingerprint key).
133
+
134
+ Written once by :func:`_ensure_depth_stats` during preview generation;
135
+ survives restarts so neither the stats endpoint nor a re-transcode ever
136
+ pays a second full-stream decode for the same source file.
137
+ """
138
+ try:
139
+ path = _depth_stats_path(source)
140
+ if not path.is_file():
141
+ return None
142
+ data = json.loads(path.read_text(encoding="utf-8"))
143
+ if (isinstance(data, dict)
144
+ and isinstance(data.get("q01"), (int, float))
145
+ and isinstance(data.get("q99"), (int, float))
146
+ and float(data["q99"]) > float(data["q01"])):
147
+ return data
148
+ except (OSError, ValueError):
149
+ pass
150
+ return None
151
+
152
+
153
+ def _histogram_depth_stats(source: Path) -> dict | None:
154
+ """Single-decode q01/q99 code bounds via a streaming 4096-bin histogram.
155
+
156
+ The stream is read as raw gray12le codes and histogrammed in O(1)
157
+ memory; percentiles are exact over every decoded pixel. The previous
158
+ ``select=`` sampling pass decoded the whole stream anyway while keeping
159
+ only a fraction of the frames, so nothing is lost.
160
+ """
161
+ import numpy as np
162
+
163
+ ffmpeg = shutil.which("ffmpeg")
164
+ if not ffmpeg:
165
+ return None
166
+ hist = np.zeros(4096, dtype=np.int64)
167
+ proc = subprocess.Popen(
168
+ [ffmpeg, "-v", "error", "-i", str(source), "-map", "0:v:0",
169
+ "-f", "rawvideo", "-pix_fmt", "gray12le", "-"],
170
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
171
+ try:
172
+ while True:
173
+ chunk = proc.stdout.read(8 * 1024 * 1024)
174
+ if not chunk:
175
+ break
176
+ values = np.frombuffer(chunk, dtype="<u2")
177
+ hist += np.bincount(values.astype(np.int64), minlength=4096)[:4096]
178
+ finally:
179
+ try:
180
+ proc.stdout.close()
181
+ except OSError:
182
+ pass
183
+ try:
184
+ proc.wait(timeout=5)
185
+ except subprocess.TimeoutExpired:
186
+ proc.kill()
187
+ total = int(hist.sum())
188
+ if total < 2:
189
+ return None
190
+ cumulative = np.cumsum(hist)
191
+ q01 = float(np.searchsorted(cumulative, total * 0.01, side="right"))
192
+ q99 = float(np.searchsorted(cumulative, total * 0.99, side="right"))
193
+ return {"q01": q01, "q99": q99, "total_pixels": total}
194
+
195
+
196
+ def _ensure_depth_stats(source: Path, local: Path | None = None) -> dict | None:
197
+ """Return q01/q99 for ``source``, computed once and persisted to disk.
198
+
199
+ ``source`` is the authoritative path used for the cache fingerprint;
200
+ ``local`` (the transport mirror) is decoded when it is ready so the pass
201
+ never competes with an SSHFS random read.
202
+ """
203
+ cached = read_depth_stats(source)
204
+ if cached is not None:
205
+ return cached
206
+ with _lock_for(f"stats:{_cache_key(source)}"):
207
+ cached = read_depth_stats(source)
208
+ if cached is not None:
209
+ return cached
210
+ stats = _histogram_depth_stats(local or source)
211
+ if stats is None:
212
+ return None
213
+ try:
214
+ _depth_stats_path(source).write_text(
215
+ json.dumps(stats), encoding="utf-8")
216
+ except OSError:
217
+ pass
218
+ return stats
219
+
220
+
221
+ def depth_preview_exists(source: Path) -> bool:
222
+ """Cheap cache-hit check for prewarm scheduling (no transcode)."""
223
+ try:
224
+ destination = _cache_root() / f"depth-{_cache_key(Path(source))}.mp4"
225
+ return destination.is_file() and destination.stat().st_size > 0
226
+ except OSError:
227
+ return False
228
+
229
+
230
+ def ensure_depth_h264_preview(source: Path, q01: float = 0.0,
231
+ q99: float = 4095.0) -> Path:
232
+ """Generate (once) an 8-bit Turbo-colored H.264 preview of a depth stream.
233
+
234
+ The browser display pipeline already quantizes codes into an 8-bit Turbo
235
+ ramp; this bakes the identical mapping (same clamp/scale math, same
236
+ colormap) into a disposable local MP4 so the review page plays it through
237
+ a native ``<video>`` element — instant open, native seek, no per-open
238
+ 12-bit decode or 229MB raw-code transfer. The preview never enters the
239
+ dataset; the canonical gray12le stream stays authoritative.
240
+ """
241
+ source = Path(source)
242
+ ffmpeg = shutil.which("ffmpeg")
243
+ if not ffmpeg:
244
+ raise RuntimeError("ffmpeg is required to build depth previews")
245
+ key = _cache_key(source)
246
+ destination = _cache_root() / f"depth-{key}.mp4"
247
+ if destination.is_file() and destination.stat().st_size > 0:
248
+ return destination
249
+ with _lock_for(key):
250
+ if destination.is_file() and destination.stat().st_size > 0:
251
+ return destination
252
+ q01 = float(q01 if q01 is not None else 0.0)
253
+ q99 = float(q99 if q99 is not None else 4095.0)
254
+ span = max(q99 - q01, 1.0)
255
+ # Decode from the local mirror when it is ready; SSHFS random reads
256
+ # during the stats/transcode passes otherwise dominate generation.
257
+ # Wait briefly for the background copy — a ~6s copy is cheaper than
258
+ # decoding the whole stream over the mount.
259
+ original = source
260
+ try:
261
+ source = ensure_local_mirror(source, wait_seconds=45)
262
+ except Exception:
263
+ pass
264
+ if q01 <= 0 and q99 >= 4095:
265
+ # No cached adaptive bounds: compute them once from a single
266
+ # decode (persisted next to the preview, keyed on the
267
+ # authoritative source path) so the ramp matches the former
268
+ # q01/q99 colorization and never re-decodes for the same file.
269
+ stats = _ensure_depth_stats(original, source)
270
+ if stats and float(stats.get("q99") or 0) > float(stats.get("q01") or 0):
271
+ q01 = float(stats["q01"])
272
+ q99 = float(stats["q99"])
273
+ span = max(q99 - q01, 1.0)
274
+ # Map the range in the native 12-bit domain (the lut filter evaluates
275
+ # ``val`` at the input depth), then apply a 4096-entry Turbo cube:
276
+ # quantizing through an 8-bit intermediate produced visible banding.
277
+ cube = _cache_root() / "turbo.cube"
278
+ _write_turbo_cube(cube)
279
+ temporary = destination.with_name(
280
+ f".{destination.stem}.{os.getpid()}.part.mp4")
281
+ temporary.unlink(missing_ok=True)
282
+ try:
283
+ subprocess.run(
284
+ [ffmpeg, "-y", "-loglevel", "error", "-i", str(source),
285
+ "-vf", (f"lut=y='clip((val-{q01:.2f})*4095/{span:.2f},0,4095)',"
286
+ f"lut1d=file={cube},format=yuv420p"),
287
+ "-an", "-c:v", "libx264", "-preset", "veryfast",
288
+ "-crf", "18", "-g", "25", "-keyint_min", "25",
289
+ "-sc_threshold", "0", "-movflags", "+faststart",
290
+ str(temporary)],
291
+ check=True, capture_output=True, timeout=3600,
292
+ )
293
+ if not temporary.is_file() or temporary.stat().st_size == 0:
294
+ raise RuntimeError("ffmpeg produced an empty depth preview")
295
+ os.replace(temporary, destination)
296
+ except subprocess.TimeoutExpired as exc:
297
+ raise RuntimeError("depth preview transcode timed out") from exc
298
+ except subprocess.CalledProcessError as exc:
299
+ detail = (exc.stderr or b"").decode("utf-8", errors="replace")[-500:]
300
+ raise RuntimeError(f"depth preview transcode failed: {detail}") from exc
301
+ finally:
302
+ temporary.unlink(missing_ok=True)
303
+ return destination
304
+
305
+
306
+ def ensure_local_mirror(source: Path, wait_seconds: float = 0.0) -> Path:
307
+ """Return a local copy of ``source`` when ready, else ``source``.
308
+
309
+ The first request for a video schedules an asynchronous copy and keeps
310
+ serving from the original path; once the mirror lands (atomic rename),
311
+ every later call — including the next frame of the same request stream —
312
+ returns the local path. The key includes size+mtime, so a reprocessed
313
+ MP4 silently invalidates the old mirror.
314
+
315
+ ``wait_seconds > 0`` polls for the copy to land before falling back to
316
+ the remote path (used by preview generation, whose decode pass is much
317
+ faster from local disk than over SSHFS).
318
+ """
319
+ source = Path(source)
320
+ key = _cache_key(source)
321
+ destination = _mirror_root() / f"{key}.mp4"
322
+ if destination.is_file() and destination.stat().st_size > 0:
323
+ return destination
324
+ with _MIRROR_INFLIGHT_GUARD:
325
+ if key not in _MIRROR_INFLIGHT:
326
+ _MIRROR_INFLIGHT.add(key)
327
+ threading.Thread(
328
+ target=_copy_mirror, args=(source, destination, key),
329
+ daemon=True,
330
+ ).start()
331
+ if wait_seconds > 0:
332
+ deadline = time.monotonic() + wait_seconds
333
+ while time.monotonic() < deadline:
334
+ if destination.is_file() and destination.stat().st_size > 0:
335
+ return destination
336
+ time.sleep(0.25)
337
+ return destination if destination.is_file() else source
338
+ return source
339
+
340
+
341
  def _is_h264_yuv420(source: Path) -> bool:
342
  """Avoid re-encoding an already browser-compatible source."""
343
  ffprobe = shutil.which("ffprobe")
 
356
  return False
357
 
358
 
359
+ def _probe_faststart(source: Path) -> bool:
360
+ """moov 在 mdat 之前 = faststart(浏览器可流式播放,否则播放会早停)。
361
+
362
+ 只读前几个顶层 box 的头部,moov/mdat 先后顺序即可判定;探测失败
363
+ 保守返回 True(透传原文件,维持旧行为)。结果按源文件指纹记忆。
364
+ """
365
+ key = _cache_key(source)
366
+ with _FASTSTART_CACHE_LOCK:
367
+ cached = _FASTSTART_CACHE.get(key)
368
+ if cached is not None:
369
+ return cached
370
+ result = True
371
+ try:
372
+ with open(source, "rb") as handle:
373
+ handle.seek(0, os.SEEK_END)
374
+ size = handle.tell()
375
+ handle.seek(0)
376
+ for _ in range(8):
377
+ if handle.tell() > size - 8:
378
+ break
379
+ pos = handle.tell()
380
+ header = handle.read(8)
381
+ if len(header) < 8:
382
+ break
383
+ length = int.from_bytes(header[:4], "big")
384
+ box_type = header[4:8]
385
+ if length == 1:
386
+ ext = handle.read(8)
387
+ length = int.from_bytes(ext, "big") if len(ext) == 8 else 0
388
+ if length == 0:
389
+ length = size - pos
390
+ if length < 8:
391
+ break
392
+ if box_type == b"moov":
393
+ result = True
394
+ break
395
+ if box_type == b"mdat":
396
+ result = False
397
+ break
398
+ handle.seek(pos + length)
399
+ except OSError:
400
+ result = True
401
+ with _FASTSTART_CACHE_LOCK:
402
+ _FASTSTART_CACHE[key] = result
403
+ return result
404
+
405
+
406
+ def _remux_faststart(source: Path, destination: Path) -> None:
407
+ """``-c copy -movflags +faststart`` 容器重排(不重编码,字节级无损)。
408
+
409
+ moov 索引移到文件头,浏览器无需跳到文件尾即可开始流式播放。
410
+ """
411
+ ffmpeg = shutil.which("ffmpeg")
412
+ if not ffmpeg:
413
+ raise RuntimeError("ffmpeg is required for faststart remux")
414
+ temporary = destination.with_name(
415
+ f".{destination.stem}.{os.getpid()}.part.mp4")
416
+ temporary.unlink(missing_ok=True)
417
+ try:
418
+ subprocess.run(
419
+ [ffmpeg, "-y", "-loglevel", "error", "-i", str(source),
420
+ "-map", "0:v:0", "-an", "-c:v", "copy",
421
+ "-movflags", "+faststart", str(temporary)],
422
+ check=True, capture_output=True, timeout=600,
423
+ )
424
+ if not temporary.is_file() or temporary.stat().st_size == 0:
425
+ raise RuntimeError("faststart remux produced an empty file")
426
+ os.replace(temporary, destination)
427
+ except subprocess.TimeoutExpired as exc:
428
+ raise RuntimeError("faststart remux timed out") from exc
429
+ except subprocess.CalledProcessError as exc:
430
+ detail = (exc.stderr or b"").decode("utf-8", errors="replace")[-300:]
431
+ raise RuntimeError(f"faststart remux failed: {detail}") from exc
432
+ finally:
433
+ temporary.unlink(missing_ok=True)
434
+
435
+
436
  def ensure_h264_preview(source: Path) -> Path:
437
  """Return an H.264/yuv420p MP4 suitable for HTML5 playback.
438
 
439
+ Browser-decodable sources are passed through directly when their moov
440
+ index sits at the front (faststart); non-faststart files are remuxed
441
+ once into the local cache (``-c copy``, byte-lossless, only the
442
+ container box order changes) so HTML5 streaming does not stall a few
443
+ seconds into playback. Remux failure falls back to the original file.
444
+
445
  Callers should run this function via ``asyncio.to_thread`` so a cold
446
  transcode never blocks FastAPI's event loop.
447
  """
448
  source = Path(source)
449
  if not source.is_file():
450
  raise FileNotFoundError(f"Video source not found: {source}")
 
 
451
 
452
  ffmpeg = shutil.which("ffmpeg")
453
  if not ffmpeg:
 
457
  destination = _cache_root() / f"{key}.mp4"
458
  if destination.is_file() and destination.stat().st_size > 0:
459
  return destination
460
+ if _is_h264_yuv420(source) and _probe_faststart(source):
461
+ return source
462
 
463
  with _lock_for(key):
464
  if destination.is_file() and destination.stat().st_size > 0:
465
  return destination
466
+ if _is_h264_yuv420(source):
467
+ # 非 faststart 的 h264:一次性无损重排 moov 到文件头。
468
+ # remux 失败回退原文件,绝不阻断播放。
469
+ try:
470
+ _remux_faststart(source, destination)
471
+ if destination.is_file() and destination.stat().st_size > 0:
472
+ return destination
473
+ except Exception:
474
+ pass
475
+ return source
476
 
477
  # Keep the final .mp4 suffix so ffmpeg can infer the output muxer.
478
  temporary = destination.with_name(
processor/app/config.py CHANGED
@@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings
7
  class Settings(BaseSettings):
8
  # Database. In local mode this normally points to the server through an
9
  # SSH tunnel (for example 127.0.0.1:15432).
10
- DATABASE_URL: str = "postgresql+asyncpg://user:password@localhost:5432/data_acq"
11
  DB_CONNECT_TIMEOUT: float = 5.0
12
  DB_COMMAND_TIMEOUT: float = 10.0
13
  DB_POOL_TIMEOUT: float = 5.0
@@ -16,12 +16,12 @@ class Settings(BaseSettings):
16
  # SFTP_ROOT on the storage server.
17
  STORAGE_DIR: str = "data"
18
  STORAGE_BACKEND: str = "local" # local | sftp
19
- SFTP_HOST: str = "localhost"
20
  SFTP_PORT: int = 22
21
- SFTP_USERNAME: str = ""
22
  SFTP_PASSWORD: str = ""
23
  SFTP_KEY_FILE: str = ""
24
- SFTP_ROOT: str = ""
25
  SFTP_KNOWN_HOSTS: str = ""
26
  SFTP_STRICT_HOST_KEY: bool = False
27
  SFTP_CONNECT_TIMEOUT: int = 15
@@ -46,7 +46,7 @@ class Settings(BaseSettings):
46
  # Local server
47
  HOST: str = "0.0.0.0"
48
  PORT: int = 8000
49
- PUBLIC_BASE_URL: str = "http://127.0.0.1:8000"
50
  LOG_LEVEL: str = "info"
51
 
52
  # Cleaning
 
7
  class Settings(BaseSettings):
8
  # Database. In local mode this normally points to the server through an
9
  # SSH tunnel (for example 127.0.0.1:15432).
10
+ DATABASE_URL: str = "postgresql+asyncpg://odoo:myodoo@172.19.0.2:5432/data_acq"
11
  DB_CONNECT_TIMEOUT: float = 5.0
12
  DB_COMMAND_TIMEOUT: float = 10.0
13
  DB_POOL_TIMEOUT: float = 5.0
 
16
  # SFTP_ROOT on the storage server.
17
  STORAGE_DIR: str = "data"
18
  STORAGE_BACKEND: str = "local" # local | sftp
19
+ SFTP_HOST: str = "192.168.110.41"
20
  SFTP_PORT: int = 22
21
+ SFTP_USERNAME: str = "Stouch"
22
  SFTP_PASSWORD: str = ""
23
  SFTP_KEY_FILE: str = ""
24
+ SFTP_ROOT: str = "/vol1/docker/odoo-dev/Data Acquisition/data"
25
  SFTP_KNOWN_HOSTS: str = ""
26
  SFTP_STRICT_HOST_KEY: bool = False
27
  SFTP_CONNECT_TIMEOUT: int = 15
 
46
  # Local server
47
  HOST: str = "0.0.0.0"
48
  PORT: int = 8000
49
+ PUBLIC_BASE_URL: str = "http://192.168.110.41:2586"
50
  LOG_LEVEL: str = "info"
51
 
52
  # Cleaning
processor/app/database.py CHANGED
@@ -178,7 +178,7 @@ async def get_session() -> AsyncSession:
178
  async def _seed_default_users():
179
  """Insert the configurable bootstrap administrator if the table is empty.
180
 
181
- The public template uses a configurable demo account for a first local demo only. Existing
182
  databases are never modified because seeding stops when any user exists.
183
  Production deployments should set both bootstrap environment variables to
184
  private values before the first startup.
@@ -193,9 +193,9 @@ async def _seed_default_users():
193
  if cnt > 0:
194
  return # Already seeded
195
 
196
- username = (os.environ.get("EGODATA_BOOTSTRAP_USERNAME", "demo-admin").strip()
197
- or "demo-admin")
198
- password = os.environ.get("EGODATA_BOOTSTRAP_PASSWORD", "change-me")
199
  email = (os.environ.get("EGODATA_BOOTSTRAP_EMAIL", "").strip()
200
  or f"{username.lower()}@egodata.local")
201
  defaults = [User(
 
178
  async def _seed_default_users():
179
  """Insert the configurable bootstrap administrator if the table is empty.
180
 
181
+ The public template uses Root/Root for a first local demo only. Existing
182
  databases are never modified because seeding stops when any user exists.
183
  Production deployments should set both bootstrap environment variables to
184
  private values before the first startup.
 
193
  if cnt > 0:
194
  return # Already seeded
195
 
196
+ username = (os.environ.get("EGODATA_BOOTSTRAP_USERNAME", "Root").strip()
197
+ or "Root")
198
+ password = os.environ.get("EGODATA_BOOTSTRAP_PASSWORD", "Root")
199
  email = (os.environ.get("EGODATA_BOOTSTRAP_EMAIL", "").strip()
200
  or f"{username.lower()}@egodata.local")
201
  defaults = [User(
processor/app/lerobot_export.py CHANGED
@@ -492,6 +492,65 @@ def _probe_video(path: Path) -> tuple[int, float, int, int]:
492
  return 0, 0.0, 0, 0
493
 
494
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  def _copy_depth_video_canonical(source: Path, destination: Path) -> None:
496
  """Copy a depth stream and normalize HEVC's MP4 sample entry.
497
 
@@ -1602,7 +1661,8 @@ def build_lerobot_dataset(dataset_name: str, episode_ids: list[str], output_dir:
1602
  hand_3d_paths: list[str] | None = None,
1603
  hand_3d_right_paths: list[str] | None = None,
1604
  version: str = "v3.0",
1605
- hand_3d_unit: str | None = None) -> Path:
 
1606
  """Build a LeRobot dataset (``version`` = "v2.1" | "v3.0").
1607
 
1608
  Depth sources are exported as their preview video (``is_depth_map``)
@@ -1837,19 +1897,31 @@ def build_lerobot_dataset(dataset_name: str, episode_ids: list[str], output_dir:
1837
  destination.mkdir(parents=True, exist_ok=True)
1838
  _copy_depth_video_canonical(depth_path, output_video)
1839
  total_videos += 1
1840
- _depth_count, depth_fps, depth_width, depth_height = _probe_video(depth_path)
 
 
 
 
 
 
 
 
 
 
 
 
1841
  episode_video_meta[f"observation.images.{depth_source}"] = (
1842
  _depth_count, depth_fps or fps)
1843
  has_metric = depth_source in generated_depth
1844
  video_features[f"observation.images.{depth_source}"] = {
1845
  "dtype": "video",
1846
- "shape": [depth_height or 0, depth_width or 0, 3],
1847
  "names": ["height", "width", "channel"],
1848
  "video_info": {
1849
  "video.fps": depth_fps or fps,
1850
  "video.height": depth_height or 0,
1851
  "video.width": depth_width or 0,
1852
- "video.channels": 1,
1853
  # 有 metric 流时主键是纯可视化;无 metric(老批次/
1854
  # 编码失败)才把主键当深度流兜底
1855
  "video.is_depth_map": not has_metric,
@@ -1946,6 +2018,12 @@ def build_lerobot_dataset(dataset_name: str, episode_ids: list[str], output_dir:
1946
  episode_row_ranges.append((out_episode_index, task_id, frame_count,
1947
  len(rows) - frame_count, len(rows)))
1948
  episode_video_meta_records.append(episode_video_meta)
 
 
 
 
 
 
1949
 
1950
  # 变长异步传感器列固定长度化:官方加载器不支持变长列(实测
1951
  # list<int64> 对声明 int64[1] 直接 cast 失败)。取全数据集最大
 
492
  return 0, 0.0, 0, 0
493
 
494
 
495
+ def _probe_depth_video(path: Path) -> dict:
496
+ """Probe a depth stream with ffprobe first, OpenCV as fallback.
497
+
498
+ OpenCV builds without 12-bit HEVC support silently report zeros for
499
+ gray12le streams. ffprobe returns the true pixel format and dimensions;
500
+ the container duration estimates the frame count when ``nb_frames`` is
501
+ absent from the stream entry (common for HEVC).
502
+ """
503
+ import json
504
+ import subprocess
505
+
506
+ result = {"frames": 0, "fps": 0.0, "width": 0, "height": 0, "pix_fmt": ""}
507
+ try:
508
+ completed = subprocess.run(
509
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
510
+ "-show_entries",
511
+ "stream=codec_name,pix_fmt,width,height,r_frame_rate,nb_frames:"
512
+ "format=duration",
513
+ "-of", "json", str(path)],
514
+ capture_output=True, text=True, timeout=15, check=False,
515
+ )
516
+ payload = json.loads(completed.stdout or "{}")
517
+ streams = payload.get("streams") or []
518
+ if streams:
519
+ stream = streams[0]
520
+ result["pix_fmt"] = str(stream.get("pix_fmt") or "")
521
+ for name in ("width", "height"):
522
+ try:
523
+ result[name] = int(stream.get(name) or 0)
524
+ except (TypeError, ValueError):
525
+ pass
526
+ try:
527
+ result["frames"] = int(stream.get("nb_frames") or 0)
528
+ except (TypeError, ValueError):
529
+ pass
530
+ rate = str(stream.get("r_frame_rate") or "").split("/")
531
+ try:
532
+ if len(rate) == 2 and float(rate[1]) > 0:
533
+ result["fps"] = float(rate[0]) / float(rate[1])
534
+ except (TypeError, ValueError, ZeroDivisionError):
535
+ pass
536
+ if not result["frames"] and result["fps"] > 0:
537
+ try:
538
+ duration = float((payload.get("format") or {}).get("duration") or 0)
539
+ except (TypeError, ValueError):
540
+ duration = 0.0
541
+ if duration > 0:
542
+ result["frames"] = int(round(duration * result["fps"]))
543
+ except (OSError, subprocess.SubprocessError, ValueError, TypeError,
544
+ json.JSONDecodeError):
545
+ pass
546
+ count, fps, width, height = _probe_video(path) # OpenCV fallback
547
+ result["frames"] = result["frames"] or count
548
+ result["fps"] = result["fps"] or float(fps or 0.0)
549
+ result["width"] = result["width"] or int(width or 0)
550
+ result["height"] = result["height"] or int(height or 0)
551
+ return result
552
+
553
+
554
  def _copy_depth_video_canonical(source: Path, destination: Path) -> None:
555
  """Copy a depth stream and normalize HEVC's MP4 sample entry.
556
 
 
1661
  hand_3d_paths: list[str] | None = None,
1662
  hand_3d_right_paths: list[str] | None = None,
1663
  version: str = "v3.0",
1664
+ hand_3d_unit: str | None = None,
1665
+ progress_callback=None) -> Path:
1666
  """Build a LeRobot dataset (``version`` = "v2.1" | "v3.0").
1667
 
1668
  Depth sources are exported as their preview video (``is_depth_map``)
 
1897
  destination.mkdir(parents=True, exist_ok=True)
1898
  _copy_depth_video_canonical(depth_path, output_video)
1899
  total_videos += 1
1900
+ depth_probe = _probe_depth_video(depth_path)
1901
+ _depth_count = int(depth_probe.get("frames") or 0)
1902
+ depth_fps = float(depth_probe.get("fps") or fps)
1903
+ depth_width = int(depth_probe.get("width") or 0)
1904
+ depth_height = int(depth_probe.get("height") or 0)
1905
+ # The canonical stream is a single-plane gray12le. Declare the
1906
+ # channels from the actual pixel format instead of the RGB-loop
1907
+ # default of 3 so the intermediate feature is truthful before
1908
+ # _canonicalize_video_features runs downstream.
1909
+ depth_channels = (
1910
+ 1 if depth_probe.get("pix_fmt") in {"gray12le", "gray16le", "gray10le"}
1911
+ else 3
1912
+ )
1913
  episode_video_meta[f"observation.images.{depth_source}"] = (
1914
  _depth_count, depth_fps or fps)
1915
  has_metric = depth_source in generated_depth
1916
  video_features[f"observation.images.{depth_source}"] = {
1917
  "dtype": "video",
1918
+ "shape": [depth_height or 0, depth_width or 0, depth_channels],
1919
  "names": ["height", "width", "channel"],
1920
  "video_info": {
1921
  "video.fps": depth_fps or fps,
1922
  "video.height": depth_height or 0,
1923
  "video.width": depth_width or 0,
1924
+ "video.channels": depth_channels,
1925
  # 有 metric 流时主键是纯可视化;无 metric(老批次/
1926
  # 编码失败)才把主键当深度流兜底
1927
  "video.is_depth_map": not has_metric,
 
2018
  episode_row_ranges.append((out_episode_index, task_id, frame_count,
2019
  len(rows) - frame_count, len(rows)))
2020
  episode_video_meta_records.append(episode_video_meta)
2021
+ if progress_callback is not None:
2022
+ try:
2023
+ progress_callback(done=out_episode_index + 1,
2024
+ total_episodes=len(episodes))
2025
+ except Exception:
2026
+ pass # 进度回调失败不影响导出本身
2027
 
2028
  # 变长异步传感器列固定长度化:官方加载器不支持变长列(实测
2029
  # list<int64> 对声明 int64[1] 直接 cast 失败)。取全数据集最大
processor/app/lerobot_v21.py CHANGED
@@ -113,6 +113,48 @@ def depth_to_heatmap_bgr(depth_mm):
113
  return codes_to_heatmap_bgr(quantize_depth(depth_mm))
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def _depth_video_info_from_path(path: Path) -> dict[str, Any]:
117
  """Read depth metadata beside a canonical video when available."""
118
  path = Path(path)
@@ -677,6 +719,7 @@ class DepthVideoReader:
677
  raise RuntimeError(f"Metric depth video has invalid dimensions: {self.path}")
678
  self.frame_count = int(probe.get("frames") or 0)
679
  self.fps = float(probe.get("fps") or 30.0)
 
680
  self.video_info = _depth_video_info_from_path(self.path)
681
  self.mode = _depth_video_mode(self.video_info)
682
  self._frame_bytes = self.width * self.height * 2
@@ -755,12 +798,19 @@ class DepthVideoReader:
755
  def close(self) -> None:
756
  process = self._process
757
  if process.poll() is None:
 
 
 
 
758
  process.terminate()
759
- try:
760
- process.wait(timeout=5)
761
- except subprocess.TimeoutExpired:
762
- process.kill()
763
- process.wait(timeout=5)
 
 
 
764
  if process.stdout:
765
  process.stdout.close()
766
  if process.stderr:
@@ -773,6 +823,91 @@ class DepthVideoReader:
773
  self.close()
774
 
775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776
  def _to_mp4(source: Path, destination: Path) -> Path:
777
  """Put a video in the v2.1 MP4 path, preserving codec when possible."""
778
  destination.parent.mkdir(parents=True, exist_ok=True)
 
113
  return codes_to_heatmap_bgr(quantize_depth(depth_mm))
114
 
115
 
116
+ # Turbo colormap (Google) polynomial coefficients — matches Rerun's default
117
+ # depth visualization (blue → cyan → green → yellow → red continuous ramp).
118
+ _TURBO_POLY = {
119
+ "r": [0.13572138, 4.61539260, -42.66032258, 132.13108234,
120
+ -152.94239396, 59.28637943],
121
+ "g": [0.09140261, 2.19418839, 4.84296658, -14.18503333,
122
+ 4.27729857, 2.82956604],
123
+ "b": [0.10667330, 12.64194608, -60.58204836, 110.36276771,
124
+ -89.90310912, 27.34824973],
125
+ }
126
+ _TURBO_LUT: list[list[int]] | None = None
127
+
128
+
129
+ def turbo_lut(size: int = 256) -> list[list[int]]:
130
+ """N-entry Google Turbo colormap (RGB), generated once per process.
131
+
132
+ The frontend uses 256 entries; the preview transcode uses a 4096-entry
133
+ cube so the range mapping keeps 12-bit precision (a 256-entry LUT in the
134
+ 8-bit domain caused visible banding on smooth depth gradients).
135
+ """
136
+ global _TURBO_LUT
137
+ if size == 256 and _TURBO_LUT is not None:
138
+ return _TURBO_LUT
139
+ lut = []
140
+ for i in range(size):
141
+ x = i / max(1, size - 1)
142
+ rgb = []
143
+ for channel in ("r", "g", "b"):
144
+ c = _TURBO_POLY[channel]
145
+ v = c[0] + x * (c[1] + x * (c[2] + x * (c[3] + x * (c[4] + x * c[5]))))
146
+ rgb.append(int(max(0, min(255, round(v * 255)))))
147
+ lut.append(rgb)
148
+ if size == 256:
149
+ _TURBO_LUT = lut
150
+ return lut
151
+
152
+
153
+ def turbo_lut_256() -> list[list[int]]:
154
+ """256-entry Google Turbo colormap (RGB), generated once per process."""
155
+ return turbo_lut(256)
156
+
157
+
158
  def _depth_video_info_from_path(path: Path) -> dict[str, Any]:
159
  """Read depth metadata beside a canonical video when available."""
160
  path = Path(path)
 
719
  raise RuntimeError(f"Metric depth video has invalid dimensions: {self.path}")
720
  self.frame_count = int(probe.get("frames") or 0)
721
  self.fps = float(probe.get("fps") or 30.0)
722
+ self.pix_fmt = pix_fmt
723
  self.video_info = _depth_video_info_from_path(self.path)
724
  self.mode = _depth_video_mode(self.video_info)
725
  self._frame_bytes = self.width * self.height * 2
 
798
  def close(self) -> None:
799
  process = self._process
800
  if process.poll() is None:
801
+ # The decoder usually sits blocked on a full stdout pipe once the
802
+ # caller stops reading. SIGTERM makes FFmpeg try a graceful
803
+ # flush that never completes in that state (measured 5s timeouts
804
+ # per close), so give it a very short grace window and then kill.
805
  process.terminate()
806
+ try:
807
+ process.wait(timeout=0.3)
808
+ except subprocess.TimeoutExpired:
809
+ process.kill()
810
+ try:
811
+ process.wait(timeout=5)
812
+ except subprocess.TimeoutExpired:
813
+ pass
814
  if process.stdout:
815
  process.stdout.close()
816
  if process.stderr:
 
823
  self.close()
824
 
825
 
826
+ class DepthCodeSeekReader:
827
+ """Sequential code reader positioned at an exact frame via FFmpeg seek.
828
+
829
+ The canonical depth contract encodes keyint=2 (an IDR every two frames),
830
+ so input seeking lands one frame before the target at most. FFmpeg's
831
+ accurate seek drops the lead-in frames itself (verified: ``-ss`` at an
832
+ exact frame timestamp yields that frame as the first output), which turns
833
+ a scrub jump from an O(N) sequential decode into a constant-time seek.
834
+
835
+ The pooled window path replaces its sequential reader with one of these
836
+ after a jump, so forward playback continues from the new position without
837
+ re-seeking per window.
838
+ """
839
+
840
+ def __init__(self, path: Path, start_frame: int, fps: float = 30.0,
841
+ pix_fmt: str = "gray12le"):
842
+ import numpy as np
843
+
844
+ self.path = Path(path)
845
+ ffmpeg = shutil.which("ffmpeg")
846
+ if not ffmpeg:
847
+ raise RuntimeError("ffmpeg is required to seek depth video")
848
+ probe = _probe_video(self.path)
849
+ self.width = int(probe.get("width") or 0)
850
+ self.height = int(probe.get("height") or 0)
851
+ if self.width <= 0 or self.height <= 0:
852
+ raise RuntimeError(f"Depth video has invalid dimensions: {self.path}")
853
+ self._frame_bytes = self.width * self.height * 2
854
+ # Keep the stream identity so a later scrub jump on the same pool
855
+ # entry re-derives the seek timestamp from the real fps/pix_fmt
856
+ # instead of falling back to defaults (wrong seek → frame desync).
857
+ self.fps = float(fps or 30.0)
858
+ self.pix_fmt = pix_fmt
859
+ seek_time = max(0, int(start_frame)) / self.fps
860
+ # Truncate to milliseconds (never round up): ffmpeg's accurate seek
861
+ # returns the first frame with ts >= seek_time, so a rounded-up value
862
+ # such as 36.6667 lands one frame PAST the target (36.66667 -> frame
863
+ # 1101 instead of 1100). A 1ms truncation is always below the
864
+ # target frame ts and at least ~32ms above the previous frame's.
865
+ seek_time = math.floor(seek_time * 1000) / 1000
866
+ self._process = subprocess.Popen(
867
+ [ffmpeg, "-v", "error",
868
+ "-ss", f"{seek_time:.3f}", "-i", str(self.path),
869
+ "-f", "rawvideo", "-pix_fmt", pix_fmt, "pipe:1"],
870
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
871
+ )
872
+
873
+ def read_codes(self):
874
+ """Read one stored little-endian uint16 frame without colorization."""
875
+ import numpy as np
876
+
877
+ if self._process.stdout is None:
878
+ return None
879
+ chunks: list[bytes] = []
880
+ remaining = self._frame_bytes
881
+ while remaining:
882
+ data = self._process.stdout.read(remaining)
883
+ if not data:
884
+ return None
885
+ chunks.append(data)
886
+ remaining -= len(data)
887
+ return np.frombuffer(b"".join(chunks), dtype="<u2").reshape(
888
+ self.height, self.width,
889
+ ).copy()
890
+
891
+ def close(self) -> None:
892
+ process = self._process
893
+ if process.poll() is None:
894
+ # Same blocked-pipe situation as DepthVideoReader.close(); a
895
+ # graceful SIGTERM flush hangs for seconds on a full stdout pipe.
896
+ process.terminate()
897
+ try:
898
+ process.wait(timeout=0.3)
899
+ except subprocess.TimeoutExpired:
900
+ process.kill()
901
+ try:
902
+ process.wait(timeout=5)
903
+ except subprocess.TimeoutExpired:
904
+ pass
905
+ if process.stdout:
906
+ process.stdout.close()
907
+ if process.stderr:
908
+ process.stderr.close()
909
+
910
+
911
  def _to_mp4(source: Path, destination: Path) -> Path:
912
  """Put a video in the v2.1 MP4 path, preserving codec when possible."""
913
  destination.parent.mkdir(parents=True, exist_ok=True)
processor/app/localstore.py CHANGED
@@ -306,6 +306,9 @@ def _scan_project_dir(project_dir: Path, project: str) -> list[dict]:
306
  "episode_index": episode_index,
307
  "project": project_name,
308
  "status": state.get("status", "completed"),
 
 
 
309
  "fps": state_fps or declared_fps,
310
  "frame_count": frame_count,
311
  "camera_names": sorted(cameras),
 
306
  "episode_index": episode_index,
307
  "project": project_name,
308
  "status": state.get("status", "completed"),
309
+ # AI 标注运行中(running)时前端禁止查看批次;passed/failed
310
+ # 或未配置 AI 节点(null)均正常放行。
311
+ "ai_quality_status": state.get("ai_quality_status"),
312
  "fps": state_fps or declared_fps,
313
  "frame_count": frame_count,
314
  "camera_names": sorted(cameras),
processor/app/main.py CHANGED
@@ -53,7 +53,7 @@ async def lifespan(app: FastAPI):
53
  ).start()
54
  from app import upload_queue
55
  upload_queue.start()
56
- print(f"[Startup] Data Acquisition Service on port {settings.PORT}")
57
  print(f"[Startup] Storage: {settings.STORAGE_DIR} ({settings.STORAGE_BACKEND})")
58
  try:
59
  yield
@@ -63,7 +63,7 @@ async def lifespan(app: FastAPI):
63
 
64
 
65
  app = FastAPI(
66
- title="Data Acquisition Service",
67
  version="0.1.0",
68
  lifespan=lifespan,
69
  )
@@ -74,6 +74,24 @@ app.add_middleware(GZipMiddleware, minimum_size=500)
74
  # Auth middleware — protect routes (after gzip, before everything else)
75
  app.add_middleware(AuthMiddleware)
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  # Static JS/CSS is versioned (workflow assets use content hashes; the legacy
78
  # page scripts use a ``?v=...`` query). Tell the browser to retain these
79
  # resources across full page navigation instead of revalidating every common
 
53
  ).start()
54
  from app import upload_queue
55
  upload_queue.start()
56
+ print(f"[Startup] processor Service on port {settings.PORT}")
57
  print(f"[Startup] Storage: {settings.STORAGE_DIR} ({settings.STORAGE_BACKEND})")
58
  try:
59
  yield
 
63
 
64
 
65
  app = FastAPI(
66
+ title="processor Service",
67
  version="0.1.0",
68
  lifespan=lifespan,
69
  )
 
74
  # Auth middleware — protect routes (after gzip, before everything else)
75
  app.add_middleware(AuthMiddleware)
76
 
77
+ # 媒体/压缩文件下载不走 gzip:zip/mp4/parquet/hdf5 本身已是压缩格式,
78
+ # 再压一遍只是烧 CPU(zlib level 9 压缩 180MB ≈ 10s),而且流式压缩会
79
+ # 删掉 Content-Length —— 浏览器下载进度显示"未知大小",大文件看起来
80
+ # 像卡死。这些路径直接透传原始字节(去掉 accept-encoding 头即可)。
81
+ _NO_COMPRESS_PATH_HINTS = (
82
+ "download", "stream", "depth-codes", "depth-preview", "skeleton", "hdf5",
83
+ )
84
+
85
+
86
+ @app.middleware("http")
87
+ async def no_compress_media(request, call_next):
88
+ if any(hint in request.url.path for hint in _NO_COMPRESS_PATH_HINTS):
89
+ request.scope["headers"] = [
90
+ (key, value) for key, value in request.scope["headers"]
91
+ if key.lower() != b"accept-encoding"
92
+ ]
93
+ return await call_next(request)
94
+
95
  # Static JS/CSS is versioned (workflow assets use content hashes; the legacy
96
  # page scripts use a ``?v=...`` query). Tell the browser to retain these
97
  # resources across full page navigation instead of revalidating every common
processor/app/media_cache.py CHANGED
@@ -21,7 +21,7 @@ import time
21
  _lock = threading.Lock()
22
  _MEDIA_GROUPS: dict[str, tuple[float, dict]] = {}
23
  _HAND3D_META: dict[str, tuple[float, dict]] = {}
24
- _TTL = 600.0 # 兜底自愈;正常路径由 invalidate_episode 显式失效
25
 
26
 
27
  def _fresh(entry: tuple[float, dict] | None, now: float) -> bool:
 
21
  _lock = threading.Lock()
22
  _MEDIA_GROUPS: dict[str, tuple[float, dict]] = {}
23
  _HAND3D_META: dict[str, tuple[float, dict]] = {}
24
+ _TTL = 3600.0 # 兜底自愈;正常路径由 invalidate_episode 显式失效(hand-3d meta 冷计算 ~20s,10min 太短)
25
 
26
 
27
  def _fresh(entry: tuple[float, dict] | None, now: float) -> bool:
processor/app/paths.py CHANGED
@@ -13,3 +13,4 @@ WEB_ROOT = PROJECT_ROOT / "web"
13
  TEMPLATES_DIR = WEB_ROOT / "templates"
14
  STATIC_DIR = WEB_ROOT / "static"
15
  WORKFLOW_STUDIO_DIR = WEB_ROOT / "workflow-studio"
 
 
13
  TEMPLATES_DIR = WEB_ROOT / "templates"
14
  STATIC_DIR = WEB_ROOT / "static"
15
  WORKFLOW_STUDIO_DIR = WEB_ROOT / "workflow-studio"
16
+
processor/app/processing/__init__.py CHANGED
@@ -1,201 +1,201 @@
1
- """Processing Module Framework — pluggable pipeline nodes.
2
-
3
- 每个功能(相机输入、骨骼识别、手套、标注、审核、导出)是一个独立模块:
4
- 一个文件 = 一个模块(元数据 + run()),放在 app/processing/modules/ 下,
5
- 自动发现注册。前端画布与 worker 执行都从注册表取模块定义。
6
-
7
- 新增自定义模块三步:
8
- 1. 复制 modules/example 模板为 app/processing/modules/<slug>.py
9
- 2. 填写 slug/label/category/inputs/outputs/config_schema 等元数据,实现 run(ctx)
10
- 3. 重启后端与 worker —— 前端面板自动出现可拖拽卡片,worker 自动执行
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from dataclasses import dataclass, field as dc_field
16
- from pathlib import Path
17
- from typing import Any, Callable
18
-
19
-
20
- def field(name: str, kind: str, label: str, default=None, **extra) -> dict:
21
- """config_schema 单字段定义: {"name","type","label","default",**extra}。"""
22
- value = {"name": name, "type": kind, "label": label, "default": default}
23
- value.update(extra)
24
- return value
25
-
26
-
27
- @dataclass
28
- class ModuleResult:
29
- """Returned by a ProcessingModule after execution (legacy, kept for compat)."""
30
- success: bool
31
- data: dict[str, Any] = dc_field(default_factory=dict)
32
- outputs: dict[str, "ArtifactRef"] = dc_field(default_factory=dict)
33
- metrics: dict[str, Any] = dc_field(default_factory=dict)
34
- error: str | None = None
35
-
36
-
37
- @dataclass
38
- class ArtifactRef:
39
- """Portable reference to a workflow input/output artifact."""
40
-
41
- kind: str
42
- path: str | None = None
43
- source_key: str | None = None
44
- schema_version: str = "1.0"
45
- metadata: dict[str, Any] = dc_field(default_factory=dict)
46
-
47
- def to_dict(self) -> dict[str, Any]:
48
- return {
49
- "kind": self.kind,
50
- "path": self.path,
51
- "source_key": self.source_key,
52
- "schema_version": self.schema_version,
53
- "metadata": self.metadata,
54
- }
55
-
56
- @classmethod
57
- def from_dict(cls, value: dict[str, Any]) -> "ArtifactRef":
58
- return cls(
59
- kind=str(value.get("kind", "unknown")),
60
- path=value.get("path"),
61
- source_key=value.get("source_key"),
62
- schema_version=str(value.get("schema_version", "1.0")),
63
- metadata=dict(value.get("metadata") or {}),
64
- )
65
-
66
-
67
- @dataclass(frozen=True)
68
- class ModuleDescriptor:
69
- """Metadata shared by the backend module registry and card palette."""
70
-
71
- slug: str
72
- version: str
73
- category: str
74
- label: str
75
- icon: str
76
- color: str
77
- inputs: tuple[dict[str, str], ...] = ()
78
- outputs: tuple[dict[str, str], ...] = ()
79
- default_config: dict[str, Any] = dc_field(default_factory=dict)
80
- config_schema: tuple[dict[str, Any], ...] = ()
81
- execution_target: str = "worker"
82
- capabilities: tuple[str, ...] = ()
83
-
84
- def to_dict(self) -> dict[str, Any]:
85
- return {
86
- "type": self.slug,
87
- "slug": self.slug,
88
- "version": self.version,
89
- "category": self.category,
90
- "label": self.label,
91
- "icon": self.icon,
92
- "color": self.color,
93
- "inputs": list(self.inputs),
94
- "outputs": list(self.outputs),
95
- "defaultConfig": self.default_config,
96
- "default_config": self.default_config,
97
- "configSchema": list(self.config_schema),
98
- "config_schema": list(self.config_schema),
99
- "executionTarget": self.execution_target,
100
- "execution_target": self.execution_target,
101
- "capabilities": list(self.capabilities),
102
- }
103
-
104
-
105
- class ModuleSkip(Exception):
106
- """模块主动跳过(无有效输入等)—— 节点标记 skipped,不视为失败。"""
107
-
108
- def __init__(self, reason: str):
109
- self.reason = reason
110
- super().__init__(reason)
111
-
112
-
113
- @dataclass
114
- class JobContext:
115
- """Worker 与模块之间的统一执行上下文。"""
116
-
117
- node_id: str
118
- node_type: str
119
- config: dict[str, Any]
120
- job: dict[str, Any]
121
- input_root: Path # 批次解压目录(源文件扫描)
122
- output_root: Path # 输出根(outputs/)
123
- incoming: dict[str, ArtifactRef] # targetHandle → 上游 ArtifactRef(源节点为空 dict)
124
- progress: Callable[[float], None] # 本节点进度 0..1
125
- node_states: dict # 节点状态累加器
126
-
127
- @property
128
- def output_dir(self) -> Path:
129
- d = self.output_root / self.node_id
130
- d.mkdir(parents=True, exist_ok=True)
131
- return d
132
-
133
- def ref(self, kind: str, path: Path, source_key: str | None = None,
134
- metadata: dict | None = None) -> ArtifactRef:
135
- """构造 ArtifactRef,路径自动相对化:input_root 下 → 相对 input_root,否则相对 output_root。"""
136
- try:
137
- rel = path.resolve().relative_to(self.input_root.resolve())
138
- except ValueError:
139
- rel = path.resolve().relative_to(self.output_root.resolve())
140
- return ArtifactRef(kind=kind, path=str(rel).replace("\\", "/"),
141
- source_key=source_key, metadata=metadata or {})
142
-
143
- def resolve(self, ref: ArtifactRef) -> Path | None:
144
- """按 input_root/output_root 依次解析 ArtifactRef 路径。"""
145
- if not ref or not ref.path:
146
- return None
147
- p = self.input_root / ref.path
148
- if p.exists():
149
- return p
150
- p = self.output_root / ref.path
151
- return p if p.exists() else None
152
-
153
- def find_videos(self) -> list[Path]:
154
- """按 config 的 source_keys/source_key/position 匹配批次内视频(见 batch.py)。"""
155
- from app.processing.batch import find_videos
156
- return find_videos(self.input_root, self.config)
157
-
158
- def find_parquet(self) -> list[Path]:
159
- """批次内全部 parquet(排除 auto_labels/hand_kpts 等合并产物)。"""
160
- from app.processing.batch import find_parquet
161
- return find_parquet(self.input_root)
162
-
163
- def skip(self, reason: str):
164
- raise ModuleSkip(reason)
165
-
166
-
167
- class ProcessingModule:
168
- """Base class for a pipeline processing node.
169
-
170
- Subclass, fill in the metadata class attributes, implement ``run()``,
171
- and decorate with ``@register`` from app.processing.registry — the
172
- module appears in the Workflow Studio palette and runs in the worker
173
- automatically.
174
- """
175
-
176
- slug: str = ""
177
- version: str = "1.0"
178
- category: str = "process" # input | process | review | export
179
- label: str = ""
180
- icon: str = "ant-design:appstore-outlined"
181
- color: str = "#64748b"
182
- inputs: tuple[dict[str, str], ...] = ()
183
- outputs: tuple[dict[str, str], ...] = ()
184
- default_config: dict[str, Any] = dc_field(default_factory=dict)
185
- config_schema: tuple[dict[str, Any], ...] = ()
186
- execution_target: str = "worker"
187
- capabilities: tuple[str, ...] = ()
188
-
189
- @classmethod
190
- def descriptor(cls) -> ModuleDescriptor:
191
- return ModuleDescriptor(
192
- slug=cls.slug, version=cls.version, category=cls.category, label=cls.label,
193
- icon=cls.icon, color=cls.color, inputs=tuple(cls.inputs),
194
- outputs=tuple(cls.outputs), default_config=dict(cls.default_config),
195
- config_schema=tuple(cls.config_schema), execution_target=cls.execution_target,
196
- capabilities=tuple(cls.capabilities),
197
- )
198
-
199
- def run(self, ctx: JobContext) -> dict[str, ArtifactRef] | None:
200
- """执行入口。返回输出 handle → ArtifactRef;None 或 raise ModuleSkip → 节点 skipped。"""
201
- raise NotImplementedError
 
1
+ """Processing Module Framework — pluggable pipeline nodes.
2
+
3
+ 每个功能(相机输入、骨骼识别、手套、标注、审核、导出)是一个独立模块:
4
+ 一个文件 = 一个模块(元数据 + run()),放在 app/processing/modules/ 下,
5
+ 自动发现注册。前端画布与 worker 执行都从注册表取模块定义。
6
+
7
+ 新增自定义模块三步:
8
+ 1. 复制 modules/example 模板为 app/processing/modules/<slug>.py
9
+ 2. 填写 slug/label/category/inputs/outputs/config_schema 等元数据,实现 run(ctx)
10
+ 3. 重启后端与 worker —— 前端面板自动出现可拖拽卡片,worker 自动执行
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field as dc_field
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+
19
+
20
+ def field(name: str, kind: str, label: str, default=None, **extra) -> dict:
21
+ """config_schema 单字段定义: {"name","type","label","default",**extra}。"""
22
+ value = {"name": name, "type": kind, "label": label, "default": default}
23
+ value.update(extra)
24
+ return value
25
+
26
+
27
+ @dataclass
28
+ class ModuleResult:
29
+ """Returned by a ProcessingModule after execution (legacy, kept for compat)."""
30
+ success: bool
31
+ data: dict[str, Any] = dc_field(default_factory=dict)
32
+ outputs: dict[str, "ArtifactRef"] = dc_field(default_factory=dict)
33
+ metrics: dict[str, Any] = dc_field(default_factory=dict)
34
+ error: str | None = None
35
+
36
+
37
+ @dataclass
38
+ class ArtifactRef:
39
+ """Portable reference to a workflow input/output artifact."""
40
+
41
+ kind: str
42
+ path: str | None = None
43
+ source_key: str | None = None
44
+ schema_version: str = "1.0"
45
+ metadata: dict[str, Any] = dc_field(default_factory=dict)
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ return {
49
+ "kind": self.kind,
50
+ "path": self.path,
51
+ "source_key": self.source_key,
52
+ "schema_version": self.schema_version,
53
+ "metadata": self.metadata,
54
+ }
55
+
56
+ @classmethod
57
+ def from_dict(cls, value: dict[str, Any]) -> "ArtifactRef":
58
+ return cls(
59
+ kind=str(value.get("kind", "unknown")),
60
+ path=value.get("path"),
61
+ source_key=value.get("source_key"),
62
+ schema_version=str(value.get("schema_version", "1.0")),
63
+ metadata=dict(value.get("metadata") or {}),
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ModuleDescriptor:
69
+ """Metadata shared by the backend module registry and card palette."""
70
+
71
+ slug: str
72
+ version: str
73
+ category: str
74
+ label: str
75
+ icon: str
76
+ color: str
77
+ inputs: tuple[dict[str, str], ...] = ()
78
+ outputs: tuple[dict[str, str], ...] = ()
79
+ default_config: dict[str, Any] = dc_field(default_factory=dict)
80
+ config_schema: tuple[dict[str, Any], ...] = ()
81
+ execution_target: str = "worker"
82
+ capabilities: tuple[str, ...] = ()
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ return {
86
+ "type": self.slug,
87
+ "slug": self.slug,
88
+ "version": self.version,
89
+ "category": self.category,
90
+ "label": self.label,
91
+ "icon": self.icon,
92
+ "color": self.color,
93
+ "inputs": list(self.inputs),
94
+ "outputs": list(self.outputs),
95
+ "defaultConfig": self.default_config,
96
+ "default_config": self.default_config,
97
+ "configSchema": list(self.config_schema),
98
+ "config_schema": list(self.config_schema),
99
+ "executionTarget": self.execution_target,
100
+ "execution_target": self.execution_target,
101
+ "capabilities": list(self.capabilities),
102
+ }
103
+
104
+
105
+ class ModuleSkip(Exception):
106
+ """模块主动跳过(无有效输入等)—— 节点标记 skipped,不视为失败。"""
107
+
108
+ def __init__(self, reason: str):
109
+ self.reason = reason
110
+ super().__init__(reason)
111
+
112
+
113
+ @dataclass
114
+ class JobContext:
115
+ """Worker 与模块之间的统一执行上下文。"""
116
+
117
+ node_id: str
118
+ node_type: str
119
+ config: dict[str, Any]
120
+ job: dict[str, Any]
121
+ input_root: Path # 批次解压目录(源文件扫描)
122
+ output_root: Path # 输出根(outputs/)
123
+ incoming: dict[str, ArtifactRef] # targetHandle → 上游 ArtifactRef(源节点为空 dict)
124
+ progress: Callable[[float], None] # 本节点进度 0..1
125
+ node_states: dict # 节点状态累加器
126
+
127
+ @property
128
+ def output_dir(self) -> Path:
129
+ d = self.output_root / self.node_id
130
+ d.mkdir(parents=True, exist_ok=True)
131
+ return d
132
+
133
+ def ref(self, kind: str, path: Path, source_key: str | None = None,
134
+ metadata: dict | None = None) -> ArtifactRef:
135
+ """构造 ArtifactRef,路径自动相对化:input_root 下 → 相对 input_root,否则相对 output_root。"""
136
+ try:
137
+ rel = path.resolve().relative_to(self.input_root.resolve())
138
+ except ValueError:
139
+ rel = path.resolve().relative_to(self.output_root.resolve())
140
+ return ArtifactRef(kind=kind, path=str(rel).replace("\\", "/"),
141
+ source_key=source_key, metadata=metadata or {})
142
+
143
+ def resolve(self, ref: ArtifactRef) -> Path | None:
144
+ """按 input_root/output_root 依次解析 ArtifactRef 路径。"""
145
+ if not ref or not ref.path:
146
+ return None
147
+ p = self.input_root / ref.path
148
+ if p.exists():
149
+ return p
150
+ p = self.output_root / ref.path
151
+ return p if p.exists() else None
152
+
153
+ def find_videos(self) -> list[Path]:
154
+ """按 config 的 source_keys/source_key/position 匹配批次内视频(见 batch.py)。"""
155
+ from app.processing.batch import find_videos
156
+ return find_videos(self.input_root, self.config)
157
+
158
+ def find_parquet(self) -> list[Path]:
159
+ """批次内全部 parquet(排除 auto_labels/hand_kpts 等合并产物)。"""
160
+ from app.processing.batch import find_parquet
161
+ return find_parquet(self.input_root)
162
+
163
+ def skip(self, reason: str):
164
+ raise ModuleSkip(reason)
165
+
166
+
167
+ class ProcessingModule:
168
+ """Base class for a pipeline processing node.
169
+
170
+ Subclass, fill in the metadata class attributes, implement ``run()``,
171
+ and decorate with ``@register`` from app.processing.registry — the
172
+ module appears in the Workflow Studio palette and runs in the worker
173
+ automatically.
174
+ """
175
+
176
+ slug: str = ""
177
+ version: str = "1.0"
178
+ category: str = "process" # input | process | review | export
179
+ label: str = ""
180
+ icon: str = "ant-design:appstore-outlined"
181
+ color: str = "#64748b"
182
+ inputs: tuple[dict[str, str], ...] = ()
183
+ outputs: tuple[dict[str, str], ...] = ()
184
+ default_config: dict[str, Any] = dc_field(default_factory=dict)
185
+ config_schema: tuple[dict[str, Any], ...] = ()
186
+ execution_target: str = "worker"
187
+ capabilities: tuple[str, ...] = ()
188
+
189
+ @classmethod
190
+ def descriptor(cls) -> ModuleDescriptor:
191
+ return ModuleDescriptor(
192
+ slug=cls.slug, version=cls.version, category=cls.category, label=cls.label,
193
+ icon=cls.icon, color=cls.color, inputs=tuple(cls.inputs),
194
+ outputs=tuple(cls.outputs), default_config=dict(cls.default_config),
195
+ config_schema=tuple(cls.config_schema), execution_target=cls.execution_target,
196
+ capabilities=tuple(cls.capabilities),
197
+ )
198
+
199
+ def run(self, ctx: JobContext) -> dict[str, ArtifactRef] | None:
200
+ """执行入口。返回输出 handle → ArtifactRef;None 或 raise ModuleSkip → 节点 skipped。"""
201
+ raise NotImplementedError
processor/app/processing/black_glove/README.md CHANGED
@@ -24,7 +24,7 @@ Before publishing a public repository, verify the redistribution terms for the
24
  bundled model files. If the model terms do not allow redistribution, keep the
25
  code layout and replace the files with a documented download step.
26
 
27
- The Python dependencies are declared in the parent `Data Acquisition`
28
  `requirements.txt`. RTMPose model assets may be downloaded by `rtmlib` on
29
  first use and cached by the Python environment; this is a package/runtime
30
  dependency, not a dependency on another repository directory.
 
24
  bundled model files. If the model terms do not allow redistribution, keep the
25
  code layout and replace the files with a documented download step.
26
 
27
+ The Python dependencies are declared in the parent `processor`
28
  `requirements.txt`. RTMPose model assets may be downloaded by `rtmlib` on
29
  first use and cached by the Python environment; this is a package/runtime
30
  dependency, not a dependency on another repository directory.
processor/app/processing/black_glove/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Self-contained black-glove detection support for the Data Acquisition worker.
2
 
3
  The workflow adapter imports this package directly. It intentionally does not
4
  depend on the legacy repository-level demo directories.
 
1
+ """Self-contained black-glove detection support for the processor worker.
2
 
3
  The workflow adapter imports this package directly. It intentionally does not
4
  depend on the legacy repository-level demo directories.
processor/app/processing/black_glove/d435_hands_demo.py CHANGED
@@ -92,11 +92,11 @@ BAND_HALF_M = 0.12 # 手深带半宽(米)
92
  BAND_MIN_VALID = 4
93
  GATE_M = 0.15 # 时序一致性门(米/帧)
94
 
95
- # 内嵌出厂标定(示例回退值;生产环境应使用录制会话中的标定文件)
96
  # head_stereo.json 交叉核对零警告)。仅供无 --calib、无脚本旁 JSON 时
97
  # 回退;深度内参权威来源是录制会话的 head_stereo.json。
98
  _EMBEDDED_CALIB = {
99
- "serial": "sample-d435",
100
  "color_intrinsics": {
101
  "width": 1280, "height": 720,
102
  "fx": 912.2550048828125, "fy": 910.3241577148438,
@@ -356,18 +356,36 @@ class MediaPipeHandPipeline:
356
  self._pixel_filters: Dict[Tuple[int, int], OneEuroFilter2D] = {}
357
  self._world_filters: Dict[Tuple[int, int], OneEuroFilter3D] = {}
358
 
359
- # 构建 landmarker
360
- options = vision.HandLandmarkerOptions(
361
- base_options=mp_python.BaseOptions(model_asset_path=model_path),
362
- running_mode=vision.RunningMode.VIDEO,
363
- num_hands=num_hands,
364
- min_hand_detection_confidence=det_conf,
365
- min_hand_presence_confidence=0.5,
366
- min_tracking_confidence=track_conf,
367
- )
368
- self._landmarker = vision.HandLandmarker.create_from_options(options)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  self._preprocessor = _Preprocessor()
370
- self._t0 = time.perf_counter()
 
371
 
372
  def process(self, frame: np.ndarray) -> FrameResult:
373
  """处理一帧 BGR 图像,返回 FrameResult(.hands: [HandResult, ...])。"""
 
92
  BAND_MIN_VALID = 4
93
  GATE_M = 0.15 # 时序一致性门(米/帧)
94
 
95
+ # 内嵌出厂标定(开发机 D435,serial 213622075531,设备提取 + 录制期
96
  # head_stereo.json 交叉核对零警告)。仅供无 --calib、无脚本旁 JSON 时
97
  # 回退;深度内参权威来源是录制会话的 head_stereo.json。
98
  _EMBEDDED_CALIB = {
99
+ "serial": "213622075531",
100
  "color_intrinsics": {
101
  "width": 1280, "height": 720,
102
  "fx": 912.2550048828125, "fy": 910.3241577148438,
 
356
  self._pixel_filters: Dict[Tuple[int, int], OneEuroFilter2D] = {}
357
  self._world_filters: Dict[Tuple[int, int], OneEuroFilter3D] = {}
358
 
359
+ # 构建 landmarker:优先 GPU delegate,初始化/首帧冒烟失败回退 CPU
360
+ # (与 rgb_hand_3d 模块同一策略;Blackwell 等新卡不支持时自动回退)。
361
+ def make(delegate):
362
+ return vision.HandLandmarker.create_from_options(
363
+ vision.HandLandmarkerOptions(
364
+ base_options=mp_python.BaseOptions(
365
+ model_asset_path=model_path, delegate=delegate),
366
+ running_mode=vision.RunningMode.VIDEO,
367
+ num_hands=num_hands,
368
+ min_hand_detection_confidence=det_conf,
369
+ min_hand_presence_confidence=0.5,
370
+ min_tracking_confidence=track_conf,
371
+ ))
372
+
373
+ self._landmarker = make(mp_python.BaseOptions.Delegate.CPU)
374
+ try:
375
+ candidate = make(mp_python.BaseOptions.Delegate.GPU)
376
+ # 冒烟:VIDEO 模式要求时间戳单调递增,逐帧新建 Image 喂 3 帧
377
+ for i in range(3):
378
+ probe = mp.Image(image_format=mp.ImageFormat.SRGB,
379
+ data=np.zeros((64, 64, 3), np.uint8))
380
+ candidate.detect_for_video(probe, i)
381
+ self._landmarker.close()
382
+ self._landmarker = candidate
383
+ print("[d435_hands_demo] HandLandmarker GPU delegate active")
384
+ except Exception as exc:
385
+ print(f"[d435_hands_demo] HandLandmarker GPU 初始化失败({exc}),回退 CPU")
386
  self._preprocessor = _Preprocessor()
387
+ # _t0 预借 50ms:GPU 冒烟已用掉时间戳 0/1/2,首帧 ts 必须大于 2
388
+ self._t0 = time.perf_counter() - 0.05
389
 
390
  def process(self, frame: np.ndarray) -> FrameResult:
391
  """处理一帧 BGR 图像,返回 FrameResult(.hands: [HandResult, ...])。"""
processor/app/processing/black_glove/d435_tracking.py CHANGED
@@ -116,3 +116,4 @@ class AlphaBetaHandSlots:
116
 
117
  def close(self) -> None:
118
  self.slots = [self._new_slot(), self._new_slot()]
 
 
116
 
117
  def close(self) -> None:
118
  self.slots = [self._new_slot(), self._new_slot()]
119
+
processor/app/processing/black_glove/pose_backends.py CHANGED
@@ -29,6 +29,14 @@ class RtmposePoseBackend:
29
  name = "rtmpose"
30
 
31
  def __init__(self, device: str = "cpu"):
 
 
 
 
 
 
 
 
32
  try:
33
  from rtmlib import RTMPose
34
  self._pose = RTMPose(_RTMPOSE_URL, model_input_size=(256, 256),
@@ -78,12 +86,13 @@ class MediaPipePoseBackend:
78
  return vision.HandLandmarker.create_from_options(options)
79
 
80
  self._lm = make(mp_python.BaseOptions.Delegate.CPU)
81
- if device == "cuda":
82
  try:
83
  candidate = make(mp_python.BaseOptions.Delegate.GPU)
84
  _smoke(candidate, mp)
85
  self._lm.close()
86
  self._lm = candidate
 
87
  except Exception as exc:
88
  print(f"HandLandmarker GPU 初始化失败({exc}),回退 CPU")
89
  device = "cpu"
 
29
  name = "rtmpose"
30
 
31
  def __init__(self, device: str = "cpu"):
32
+ if device == "auto":
33
+ # auto = 有 CUDA provider 用 cuda,否则 cpu(rtmlib 不识别 auto)
34
+ try:
35
+ import onnxruntime as ort
36
+ device = ("cuda" if "CUDAExecutionProvider"
37
+ in ort.get_available_providers() else "cpu")
38
+ except Exception:
39
+ device = "cpu"
40
  try:
41
  from rtmlib import RTMPose
42
  self._pose = RTMPose(_RTMPOSE_URL, model_input_size=(256, 256),
 
86
  return vision.HandLandmarker.create_from_options(options)
87
 
88
  self._lm = make(mp_python.BaseOptions.Delegate.CPU)
89
+ if device in ("auto", "cuda"):
90
  try:
91
  candidate = make(mp_python.BaseOptions.Delegate.GPU)
92
  _smoke(candidate, mp)
93
  self._lm.close()
94
  self._lm = candidate
95
+ device = "cuda"
96
  except Exception as exc:
97
  print(f"HandLandmarker GPU 初始化失败({exc}),回退 CPU")
98
  device = "cpu"
processor/app/processing/modules/depth_hand_3d.py CHANGED
@@ -960,7 +960,7 @@ def _run_rgb_estimated_pair(pair: dict, video_path: Path, config: dict,
960
  import pandas as pd
961
 
962
  # Imported lazily to keep the depth helper independent during normal
963
- # module discovery. Both modules are inside Data Acquisition; this is
964
  # not a dependency on the legacy Python project.
965
  from app.processing.modules.stereo_triangulate import (
966
  _detect_hands, _rgb_frame_rows,
 
960
  import pandas as pd
961
 
962
  # Imported lazily to keep the depth helper independent during normal
963
+ # module discovery. Both modules are inside processor; this is
964
  # not a dependency on the legacy Python project.
965
  from app.processing.modules.stereo_triangulate import (
966
  _detect_hands, _rgb_frame_rows,
processor/app/processing/modules/rgb_world_tracking.py CHANGED
@@ -1,7 +1,7 @@
1
  """RGB-only hand-to-camera tracking used by the Hand Skeleton module.
2
 
3
  This module is deliberately self-contained. It does not import the legacy
4
- ``Python`` project or any code outside ``Data Acquisition``.
5
 
6
  MediaPipe gives us two useful pieces of information from an RGB frame:
7
 
 
1
  """RGB-only hand-to-camera tracking used by the Hand Skeleton module.
2
 
3
  This module is deliberately self-contained. It does not import the legacy
4
+ ``Python`` project or any code outside ``processor``.
5
 
6
  MediaPipe gives us two useful pieces of information from an RGB frame:
7
 
processor/app/routes/auth.py CHANGED
@@ -18,9 +18,9 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
18
  USERS_FILE = STATE_ROOT / "users.json"
19
 
20
  # Bootstrap account for a new legacy file store. Existing users.json is kept.
21
- _BOOTSTRAP_USERNAME = (os.environ.get("EGODATA_BOOTSTRAP_USERNAME", "demo-admin").strip()
22
- or "demo-admin")
23
- _BOOTSTRAP_PASSWORD = os.environ.get("EGODATA_BOOTSTRAP_PASSWORD", "change-me")
24
  _BOOTSTRAP_EMAIL = (os.environ.get("EGODATA_BOOTSTRAP_EMAIL", "").strip()
25
  or f"{_BOOTSTRAP_USERNAME.lower()}@egodata.local")
26
 
 
18
  USERS_FILE = STATE_ROOT / "users.json"
19
 
20
  # Bootstrap account for a new legacy file store. Existing users.json is kept.
21
+ _BOOTSTRAP_USERNAME = (os.environ.get("EGODATA_BOOTSTRAP_USERNAME", "Root").strip()
22
+ or "Root")
23
+ _BOOTSTRAP_PASSWORD = os.environ.get("EGODATA_BOOTSTRAP_PASSWORD", "Root")
24
  _BOOTSTRAP_EMAIL = (os.environ.get("EGODATA_BOOTSTRAP_EMAIL", "").strip()
25
  or f"{_BOOTSTRAP_USERNAME.lower()}@egodata.local")
26
 
processor/app/routes/export.py CHANGED
@@ -5,6 +5,8 @@
5
 
6
  from __future__ import annotations
7
 
 
 
8
  import tempfile
9
  import zipfile
10
  import re
@@ -17,6 +19,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
17
  from fastapi.responses import FileResponse
18
  from starlette.background import BackgroundTask
19
 
 
20
  from app.localstore import (
21
  STATE_ROOT, scan_sessions, get_episode, list_deleted_episodes, list_runs,
22
  get_workflow, list_projects, _read_json, _write_json, _remove_json,
@@ -34,6 +37,9 @@ router = APIRouter(prefix="/api/v1/export", tags=["export"])
34
 
35
  EXPORTS_DIR = STATE_ROOT / "exports"
36
  EXPORT_TASKS_DIR = STATE_ROOT / "export_tasks"
 
 
 
37
 
38
 
39
  def _utcnow() -> str:
@@ -83,6 +89,12 @@ def _zip_directory(root: Path, dest: Path) -> None:
83
  compress_type=zipfile.ZIP_STORED if path.suffix.lower() in {".mp4", ".parquet"} else zipfile.ZIP_DEFLATED)
84
 
85
 
 
 
 
 
 
 
86
  def _zip_lerobot(root: Path, dest: Path) -> None:
87
  """打包 LeRobot 标准结构(data/ + meta/ + videos/),不携带处理副产品
88
  (hand_3d/skeleton/calibration 等)。连接驱动导出带视频 feature 的
@@ -94,9 +106,12 @@ def _zip_lerobot(root: Path, dest: Path) -> None:
94
  if not sub_dir.is_dir():
95
  continue
96
  for path in sub_dir.rglob("*"):
97
- if path.is_file():
98
- archive.write(path, str(path.relative_to(root)),
99
- compress_type=zipfile.ZIP_STORED if path.suffix.lower() in {".mp4", ".parquet"} else zipfile.ZIP_DEFLATED)
 
 
 
100
 
101
 
102
  def _cleanup_later(path: str) -> None:
@@ -272,8 +287,25 @@ def _episode_export_target(episode_id: str) -> dict | None:
272
  return {"kind": "hdf5", "version": None,
273
  "product": candidate, "root": candidate.parent}
274
  elif (candidate / "meta" / "info.json").is_file():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  return {"kind": "lerobot",
276
- "version": str(cfg.get("version") or "v3.0"),
277
  "product": candidate, "root": candidate}
278
 
279
  return None
@@ -364,9 +396,11 @@ async def download_single_episode(episode_id: str):
364
  delete=False)
365
  tmp.close()
366
  _zip_lerobot(target["root"], Path(tmp.name))
367
- return FileResponse(tmp.name, media_type="application/zip",
368
- filename=f"{project_name}.zip",
369
- background=BackgroundTask(_cleanup_later, tmp.name))
 
 
370
  return _build_episode_zip([episode_id], f"egodata-{episode_id}-", f"{project_name}.zip")
371
 
372
 
@@ -415,8 +449,73 @@ def _list_tasks() -> list[dict]:
415
  return [t for t in (_read_json(f, {}) for f in sorted(EXPORT_TASKS_DIR.glob("*.json"))) if t]
416
 
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  def _run_export_task(job_id: str, episode_ids: list[str], export_format: str,
419
  dataset_name: str, split_ratio: float) -> None:
 
 
 
 
 
 
 
 
 
420
  try:
421
  blocked = [
422
  str(episode_id) for episode_id in episode_ids
@@ -425,20 +524,34 @@ def _run_export_task(job_id: str, episode_ids: list[str], export_format: str,
425
  ]
426
  if blocked:
427
  raise RuntimeError("Some episodes are still in Reviewing")
428
- dest = EXPORTS_DIR / f"{job_id}.zip"
 
 
429
  dest.parent.mkdir(parents=True, exist_ok=True)
 
430
  if export_format in {"lerobot_v2", "lerobot_v3"}:
431
  from app.lerobot_export import build_lerobot_dataset
432
- export_root = EXPORTS_DIR / f"{dataset_name}-{job_id[:8]}"
433
  version = "v2.1" if export_format == "lerobot_v2" else "v3.0"
 
 
 
 
 
434
  build_lerobot_dataset(dataset_name, episode_ids, export_root,
435
- split_ratio, version=version)
 
 
436
  _zip_directory(export_root, dest)
 
437
  else:
 
438
  _zip_episode_selection([str(e) for e in episode_ids], dest)
 
 
439
  task = _load_task(job_id)
440
  if task:
441
  task["status"] = "completed"
 
442
  task["output_dir"] = str(dest)
443
  task["finished_at"] = _utcnow()
444
  _save_task(task)
@@ -510,6 +623,32 @@ async def export_start(body: dict, background_tasks: BackgroundTasks,
510
  if export_format not in {"lerobot_v2", "lerobot_v3"}:
511
  raise HTTPException(status_code=400,
512
  detail="Unsupported export format")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  job = {
514
  "id": str(uuid4()),
515
  "dataset_name": dataset_name,
@@ -520,6 +659,7 @@ async def export_start(body: dict, background_tasks: BackgroundTasks,
520
  "export_format": export_format,
521
  "error": None,
522
  "output_dir": None,
 
523
  "created_at": _utcnow(),
524
  "finished_at": None,
525
  }
@@ -538,6 +678,8 @@ async def export_start(body: dict, background_tasks: BackgroundTasks,
538
  @router.get("/list")
539
  async def export_list(limit: int = Query(20, ge=1, le=100), offset: int = 0):
540
  tasks = sorted(_list_tasks(), key=lambda t: t.get("created_at") or "", reverse=True)
 
 
541
  return tasks[offset:offset + limit]
542
 
543
 
@@ -546,6 +688,7 @@ async def export_status(job_id: str):
546
  task = _load_task(job_id)
547
  if task is None:
548
  raise HTTPException(status_code=404, detail="Export job not found")
 
549
  return task
550
 
551
 
@@ -554,20 +697,31 @@ async def download_export_job(job_id: str):
554
  task = _load_task(job_id)
555
  if task is None:
556
  raise HTTPException(status_code=404, detail="Export job not found")
557
- dest = EXPORTS_DIR / f"{job_id}.zip"
558
- if not dest.exists():
 
 
 
559
  raise HTTPException(status_code=404, detail="Export output not ready")
560
- return FileResponse(dest, media_type="application/zip",
561
- filename=f"{task.get('dataset_name', 'dataset')}.zip")
 
 
 
 
 
562
 
563
 
564
  @router.delete("/{job_id}")
565
  async def delete_export_job(job_id: str, _: str = Depends(verify_api_key)):
 
566
  _remove_json(EXPORT_TASKS_DIR / f"{job_id}.json")
567
- try:
568
- (EXPORTS_DIR / f"{job_id}.zip").unlink(missing_ok=True)
569
- except Exception:
570
- pass
 
 
571
  return {"message": "Export job deleted"}
572
 
573
 
 
5
 
6
  from __future__ import annotations
7
 
8
+ import hashlib
9
+ import shutil
10
  import tempfile
11
  import zipfile
12
  import re
 
19
  from fastapi.responses import FileResponse
20
  from starlette.background import BackgroundTask
21
 
22
+ from app.config import settings
23
  from app.localstore import (
24
  STATE_ROOT, scan_sessions, get_episode, list_deleted_episodes, list_runs,
25
  get_workflow, list_projects, _read_json, _write_json, _remove_json,
 
37
 
38
  EXPORTS_DIR = STATE_ROOT / "exports"
39
  EXPORT_TASKS_DIR = STATE_ROOT / "export_tasks"
40
+ # 导出页/批量导出的构建目录与 zip 放本地盘:同一份视频数据不再在
41
+ # NAS(SSHFS ~28MB/s)上读写三遍,只保留一次源文件读取。
42
+ LOCAL_EXPORTS_DIR = settings.upload_staging_root / "exports"
43
 
44
 
45
  def _utcnow() -> str:
 
89
  compress_type=zipfile.ZIP_STORED if path.suffix.lower() in {".mp4", ".parquet"} else zipfile.ZIP_DEFLATED)
90
 
91
 
92
+ # 旧版本导出代码遗留的非标准边车文件:标注已在 parquet 列里
93
+ # (annotation/annotation_index/annotation_scope),设备信息在 info.json
94
+ # (robot_type)。打包时剥离,不随 zip 分发。
95
+ _SIDECAR_EXCLUDE = {"meta/annotations.jsonl", "meta/devices.json"}
96
+
97
+
98
  def _zip_lerobot(root: Path, dest: Path) -> None:
99
  """打包 LeRobot 标准结构(data/ + meta/ + videos/),不携带处理副产品
100
  (hand_3d/skeleton/calibration 等)。连接驱动导出带视频 feature 的
 
106
  if not sub_dir.is_dir():
107
  continue
108
  for path in sub_dir.rglob("*"):
109
+ if not path.is_file():
110
+ continue
111
+ if str(path.relative_to(root)) in _SIDECAR_EXCLUDE:
112
+ continue
113
+ archive.write(path, str(path.relative_to(root)),
114
+ compress_type=zipfile.ZIP_STORED if path.suffix.lower() in {".mp4", ".parquet"} else zipfile.ZIP_DEFLATED)
115
 
116
 
117
  def _cleanup_later(path: str) -> None:
 
287
  return {"kind": "hdf5", "version": None,
288
  "product": candidate, "root": candidate.parent}
289
  elif (candidate / "meta" / "info.json").is_file():
290
+ # 版本以产物自身的 codebase_version 为准(实际
291
+ # 构建版本),不能信任工作流配置 —— 旧产物可能是
292
+ # v2.1 代码按配置 v3.0 构建前的遗留,文件名/徽标
293
+ # 会与实际布局不符。
294
+ product_version = str(cfg.get("version") or "v3.0")
295
+ try:
296
+ import json as _json
297
+ info = _json.loads(
298
+ (candidate / "meta" / "info.json")
299
+ .read_text(encoding="utf-8"))
300
+ built = str(info.get("codebase_version") or "")
301
+ if built.lower().startswith("v2"):
302
+ product_version = "v2.1"
303
+ elif built.lower().startswith("v3"):
304
+ product_version = "v3.0"
305
+ except (OSError, ValueError):
306
+ pass
307
  return {"kind": "lerobot",
308
+ "version": product_version,
309
  "product": candidate, "root": candidate}
310
 
311
  return None
 
396
  delete=False)
397
  tmp.close()
398
  _zip_lerobot(target["root"], Path(tmp.name))
399
+ version = target["version"] or "v3.0"
400
+ return FileResponse(
401
+ tmp.name, media_type="application/zip",
402
+ filename=f"{project_name}-{version}.zip",
403
+ background=BackgroundTask(_cleanup_later, tmp.name))
404
  return _build_episode_zip([episode_id], f"egodata-{episode_id}-", f"{project_name}.zip")
405
 
406
 
 
449
  return [t for t in (_read_json(f, {}) for f in sorted(EXPORT_TASKS_DIR.glob("*.json"))) if t]
450
 
451
 
452
+ # running 任���超过该时长仍未结束 → 线程大概率已异常死亡,标记 failed
453
+ # (正常 3 批次导出实测 ~3 分钟;30 分钟是宽裕兜底)
454
+ _EXPORT_RUNNING_TIMEOUT_SECONDS = 30 * 60
455
+
456
+
457
+ def _mark_stale_running_failed(task: dict) -> bool:
458
+ """把卡死在 running 的超时任务标记为 failed(返回是否发生了修改)。"""
459
+ if task.get("status") != "running":
460
+ return False
461
+ try:
462
+ created = datetime.fromisoformat(
463
+ str(task.get("created_at") or "").replace("Z", "+00:00"))
464
+ elapsed = (datetime.now(timezone.utc) - created).total_seconds()
465
+ if elapsed > _EXPORT_RUNNING_TIMEOUT_SECONDS:
466
+ task["status"] = "failed"
467
+ task["error"] = ("Export timed out (running for over "
468
+ "30 minutes without finishing)")
469
+ task["finished_at"] = _utcnow()
470
+ _save_task(task)
471
+ return True
472
+ except (ValueError, TypeError):
473
+ pass
474
+ return False
475
+
476
+
477
+ def _episode_set_fingerprint(episode_ids: list[str]) -> str:
478
+ """所选批次源文件的指纹(size+mtime):数据变过才需要重新构建。"""
479
+ digest = hashlib.sha256()
480
+ for episode_id in episode_ids:
481
+ ep = get_episode(str(episode_id))
482
+ if ep is None:
483
+ continue
484
+ root = Path(ep["path"])
485
+ if not is_project_dataset(root):
486
+ continue
487
+ try:
488
+ row = episode_row(root, str(episode_id))
489
+ except Exception:
490
+ continue
491
+ if row is None:
492
+ continue
493
+ index = int(row.get("episode_index"))
494
+ files = episode_files(root, index)
495
+ candidates = [*files.get("data", []),
496
+ *(path for _, path in files.get("videos", []))]
497
+ for path in candidates:
498
+ try:
499
+ st = Path(path).stat()
500
+ digest.update(
501
+ f"{Path(path).name}|{st.st_size}|{st.st_mtime_ns}"
502
+ .encode("utf-8"))
503
+ except OSError:
504
+ continue
505
+ return digest.hexdigest()
506
+
507
+
508
  def _run_export_task(job_id: str, episode_ids: list[str], export_format: str,
509
  dataset_name: str, split_ratio: float) -> None:
510
+ def _set_progress(progress: float, **extra) -> None:
511
+ """后台任务阶段进度(前端按钮实时显示,不再静默等待)。"""
512
+ task = _load_task(job_id)
513
+ if not task:
514
+ return
515
+ task["progress"] = max(0.0, min(1.0, float(progress)))
516
+ task.update(extra)
517
+ _save_task(task)
518
+
519
  try:
520
  blocked = [
521
  str(episode_id) for episode_id in episode_ids
 
524
  ]
525
  if blocked:
526
  raise RuntimeError("Some episodes are still in Reviewing")
527
+ # 构建与 zip 都在本地盘进行(NAS 只读一次源文件);zip 是最终
528
+ # 产物,构建目录打包完成后即删除以节省本地空间。
529
+ dest = LOCAL_EXPORTS_DIR / f"{job_id}.zip"
530
  dest.parent.mkdir(parents=True, exist_ok=True)
531
+ export_root = LOCAL_EXPORTS_DIR / f"{dataset_name}-{job_id[:8]}"
532
  if export_format in {"lerobot_v2", "lerobot_v3"}:
533
  from app.lerobot_export import build_lerobot_dataset
 
534
  version = "v2.1" if export_format == "lerobot_v2" else "v3.0"
535
+
536
+ def _on_episode(done: int, total_episodes: int) -> None:
537
+ _set_progress(0.05 + 0.65 * (done / max(1, total_episodes)))
538
+
539
+ _set_progress(0.05)
540
  build_lerobot_dataset(dataset_name, episode_ids, export_root,
541
+ split_ratio, version=version,
542
+ progress_callback=_on_episode)
543
+ _set_progress(0.75)
544
  _zip_directory(export_root, dest)
545
+ _set_progress(0.95)
546
  else:
547
+ _set_progress(0.2)
548
  _zip_episode_selection([str(e) for e in episode_ids], dest)
549
+ _set_progress(0.95)
550
+ shutil.rmtree(export_root, ignore_errors=True)
551
  task = _load_task(job_id)
552
  if task:
553
  task["status"] = "completed"
554
+ task["progress"] = 1.0
555
  task["output_dir"] = str(dest)
556
  task["finished_at"] = _utcnow()
557
  _save_task(task)
 
623
  if export_format not in {"lerobot_v2", "lerobot_v3"}:
624
  raise HTTPException(status_code=400,
625
  detail="Unsupported export format")
626
+ # 防重复提交:同一批次集合已有 running 任务时直接拒绝,避免并行
627
+ # 任务互相抢 NAS 带宽(此前实测两次点击各跑一遍,时间翻倍)。
628
+ requested = set(episode_ids)
629
+ for existing in _list_tasks():
630
+ if existing.get("status") != "running":
631
+ continue
632
+ if set(existing.get("episode_ids") or []) == requested:
633
+ raise HTTPException(
634
+ status_code=409,
635
+ detail="An export for the same episodes is already running",
636
+ )
637
+ # 复用已完成结果:同批次(顺序一致)+ 同格式 + 源文件未变 → 直接
638
+ # 返回既有任务,前端轮询立即 completed 并触发下载(秒级)。
639
+ fingerprint = _episode_set_fingerprint(episode_ids)
640
+ for existing in _list_tasks():
641
+ if existing.get("status") != "completed":
642
+ continue
643
+ if (existing.get("episode_ids") or []) != episode_ids:
644
+ continue
645
+ if existing.get("export_format") != export_format:
646
+ continue
647
+ if existing.get("source_fingerprint") != fingerprint:
648
+ continue
649
+ output = Path(str(existing.get("output_dir") or ""))
650
+ if output.is_file():
651
+ return existing
652
  job = {
653
  "id": str(uuid4()),
654
  "dataset_name": dataset_name,
 
659
  "export_format": export_format,
660
  "error": None,
661
  "output_dir": None,
662
+ "source_fingerprint": fingerprint,
663
  "created_at": _utcnow(),
664
  "finished_at": None,
665
  }
 
678
  @router.get("/list")
679
  async def export_list(limit: int = Query(20, ge=1, le=100), offset: int = 0):
680
  tasks = sorted(_list_tasks(), key=lambda t: t.get("created_at") or "", reverse=True)
681
+ for task in tasks:
682
+ _mark_stale_running_failed(task)
683
  return tasks[offset:offset + limit]
684
 
685
 
 
688
  task = _load_task(job_id)
689
  if task is None:
690
  raise HTTPException(status_code=404, detail="Export job not found")
691
+ _mark_stale_running_failed(task)
692
  return task
693
 
694
 
 
697
  task = _load_task(job_id)
698
  if task is None:
699
  raise HTTPException(status_code=404, detail="Export job not found")
700
+ # 新任务 zip 在本地盘(output_dir);历史任务回退 NAS 路径。
701
+ dest = Path(str(task.get("output_dir") or ""))
702
+ if not dest.is_file():
703
+ dest = EXPORTS_DIR / f"{job_id}.zip"
704
+ if not dest.is_file():
705
  raise HTTPException(status_code=404, detail="Export output not ready")
706
+ # 文件名带格式版本后缀,下载后一眼可辨 v2.1 / v3.0
707
+ export_format = str(task.get("export_format") or "")
708
+ suffix = ("-v2.1" if export_format == "lerobot_v2"
709
+ else "-v3.0" if export_format == "lerobot_v3" else "")
710
+ return FileResponse(
711
+ dest, media_type="application/zip",
712
+ filename=f"{task.get('dataset_name', 'dataset')}{suffix}.zip")
713
 
714
 
715
  @router.delete("/{job_id}")
716
  async def delete_export_job(job_id: str, _: str = Depends(verify_api_key)):
717
+ task = _load_task(job_id)
718
  _remove_json(EXPORT_TASKS_DIR / f"{job_id}.json")
719
+ for path in (Path(str((task or {}).get("output_dir") or "")),
720
+ EXPORTS_DIR / f"{job_id}.zip"):
721
+ try:
722
+ path.unlink(missing_ok=True)
723
+ except Exception:
724
+ pass
725
  return {"message": "Export job deleted"}
726
 
727
 
processor/app/routes/ingestion.py CHANGED
@@ -6,6 +6,8 @@ data/sessions/<项目>/<批次名>/ + data/state/(审核状态、标注)。
6
 
7
  from __future__ import annotations
8
 
 
 
9
  from pathlib import Path
10
  from uuid import UUID
11
 
@@ -80,6 +82,123 @@ def _ep_to_out_brief(ep: dict) -> dict:
80
 
81
  # ── 列表 / 详情 ───────────────────────────────────────
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  @router.get("/episodes")
84
  def episode_list(status: str | None = None, limit: int = 50, offset: int = 0,
85
  brief: bool = False):
@@ -98,6 +217,11 @@ def episode_list(status: str | None = None, limit: int = 50, offset: int = 0,
98
  episodes = [e for e in episodes if e.get("status") == status]
99
  total = len(episodes)
100
  episodes = episodes[offset:offset + limit]
 
 
 
 
 
101
  serialize = _ep_to_out_brief if brief else _ep_to_out
102
  return {"episodes": [serialize(e) for e in episodes], "total": total,
103
  "limit": limit, "offset": offset}
@@ -254,6 +378,9 @@ def episode_frames_data(episode_id: str):
254
  # 列(hand_pose 等)不含这些关键字,天然排除
255
  if not ("sensor" in lower or "glove" in lower or "tactile" in lower):
256
  continue
 
 
 
257
  if sname in available_sensors:
258
  continue
259
  # 真实压力数据(全文件均匀采样)才驱动热力图
@@ -357,45 +484,6 @@ def _member_label(camera: str, role: str) -> str:
357
  return label_map.get(role, camera)
358
 
359
 
360
- def _detect_glove_sources(batch_dir, ep_id_str: str, project_name: str = "") -> list[dict]:
361
- """Detect real glove/tactile columns in the canonical episode parquet."""
362
- if batch_dir is None:
363
- return []
364
- from app.localstore import episode_has_glove_sensor
365
- episode = get_episode(ep_id_str)
366
- if episode is None:
367
- episode = {"id": ep_id_str, "project": project_name or ""}
368
- if not episode_has_glove_sensor(episode):
369
- return []
370
- try:
371
- import pyarrow.parquet as pq
372
- except ImportError:
373
- return []
374
- sources: list[dict] = []
375
- for parq_file in _canonical_data_files(Path(batch_dir), ep_id_str):
376
- try:
377
- names = pq.ParquetFile(parq_file).schema_arrow.names
378
- except Exception:
379
- continue
380
- for col in names:
381
- if not col.startswith("observation."):
382
- continue
383
- source_key = col.split(".", 1)[1]
384
- lower = source_key.lower()
385
- if not any(token in lower for token in ("glove", "sensor", "tactile")):
386
- continue
387
- if not _col_has_pressure(parq_file, col):
388
- continue
389
- sources.append({
390
- "id": f"glove:{source_key}",
391
- "kind": "glove",
392
- "source_key": source_key,
393
- "label": source_key,
394
- "heatmap_url": f"/api/v1/video/{ep_id_str}/heatmap/{{frame}}?sensor={source_key}",
395
- })
396
- return sources
397
-
398
-
399
  def _detect_depth_sources(batch_dir, ep_id_str: str, master_frame_count: int = 0,
400
  master_fps: float = 0) -> list[dict]:
401
  """Detect canonical metric-depth video streams for the review page.
@@ -441,6 +529,15 @@ def _detect_depth_sources(batch_dir, ep_id_str: str, master_frame_count: int = 0
441
  # filename alone must not turn a colour video into 3D input.
442
  if not metric_depth:
443
  continue
 
 
 
 
 
 
 
 
 
444
  frames = 0
445
  fps = float(master_fps or info.get("fps") or 0)
446
  try:
@@ -478,9 +575,11 @@ def _detect_depth_sources(batch_dir, ep_id_str: str, master_frame_count: int = 0
478
  f"/api/v1/video/{ep_id_str}/depth-codes/{source_name}/{{frame}}"),
479
  "depth_codes_window_url": (
480
  f"/api/v1/video/{ep_id_str}/depth-codes-window/"
481
- f"{source_name}?start_frame={{start}}&end_frame={{end}}"),
 
482
  "depth_codes_full_url": (
483
- f"/api/v1/video/{ep_id_str}/depth-codes-full/{source_name}"),
 
484
  # Deprecated alias retained for clients that used the old
485
  # frame URL; it now returns raw uint16 codes, never JET.
486
  "depth_preview_url": (
@@ -599,7 +698,6 @@ def episode_media_groups(episode_id: str):
599
  result["singles"].append(entry)
600
  result["sources"].append(_as_source(entry, None))
601
 
602
- result["sources"].extend(_detect_glove_sources(batch_dir, episode_id, ep.get("project") or ""))
603
  result["sources"].extend(_detect_depth_sources(
604
  batch_dir,
605
  episode_id,
 
6
 
7
  from __future__ import annotations
8
 
9
+ import threading
10
+ from collections import deque
11
  from pathlib import Path
12
  from uuid import UUID
13
 
 
82
 
83
  # ── 列表 / 详情 ───────────────────────────────────────
84
 
85
+ # ── 深度预览预热(列表加载后后台生成,点击批次时已就绪) ──────────────
86
+ # /episodes 每 15s 被前端轮询一次;把列表前 N 个有深度视频的批次排入
87
+ # 单线程队列,后台先镜像后转码。全部尽力而为:失败静默,点击路径照常
88
+ # 兜底生成。
89
+
90
+ _PREWARM_QUEUE: deque[str] = deque()
91
+ _PREWARM_QUEUED: set[str] = set()
92
+ _PREWARM_LOCK = threading.Lock()
93
+ _PREWARM_RUNNING = False
94
+ _PREWARM_MAX_PER_PASS = 8
95
+ _PREWARM_MAX_QUEUE = 24
96
+
97
+
98
+ def _episode_depth_video_paths(ep: dict) -> list[Path]:
99
+ """该批次所有深度视频文件的权威路径(预热用,宽松按名称识别)。"""
100
+ batch = Path(ep.get("path") or "")
101
+ videos = batch / "videos"
102
+ if not videos.is_dir():
103
+ return []
104
+ from app.lerobot_v21 import is_depth_source
105
+
106
+ paths: list[Path] = []
107
+ try:
108
+ for feat in videos.iterdir():
109
+ if not feat.is_dir() or not is_depth_source(feat.name):
110
+ continue
111
+ for chunk in feat.glob("chunk-*/episode_*.mp4"):
112
+ if chunk.is_file():
113
+ paths.append(chunk)
114
+ except OSError:
115
+ pass
116
+ return paths
117
+
118
+
119
+ def _prewarm_worker() -> None:
120
+ """单线程消费预热队列;无任务即退出,由下次触发重启。
121
+
122
+ 每个可见批次依次预热:素材清单(media-groups)→ 手部 3D 元数据 →
123
+ 帧数据(frames-data)→ 深度预览转码。前三项填充服务端内存缓存,
124
+ 让"点击批次"的首开在后端重启后也保持 ~1-2s(否则冷开要再付
125
+ media-groups ~7.7s + hand-3d 元数据 ~10.8s 的 NAS 读取)。
126
+ """
127
+ global _PREWARM_RUNNING
128
+ try:
129
+ while True:
130
+ with _PREWARM_LOCK:
131
+ if not _PREWARM_QUEUE:
132
+ return
133
+ episode_id = _PREWARM_QUEUE.popleft()
134
+ try:
135
+ ep = get_episode(episode_id)
136
+ if ep is None:
137
+ continue
138
+ # 1) 素材清单(点击门控的第一个 await)
139
+ try:
140
+ episode_media_groups(episode_id)
141
+ except Exception:
142
+ pass
143
+ # 2) 手部 3D 元数据(meta-only 路径,填充 hand3d meta 缓存)
144
+ try:
145
+ from app.routes.video import get_hand_3d
146
+
147
+ get_hand_3d(episode_id, None, None, None, None)
148
+ except Exception:
149
+ pass
150
+ # 3) 帧数据(手套/传感器载荷,填 _frames_data_cache)
151
+ try:
152
+ episode_frames_data(episode_id)
153
+ except Exception:
154
+ pass
155
+ # 4) 深度预览转码(已有)
156
+ for video_path in _episode_depth_video_paths(ep):
157
+ try:
158
+ from app.browser_preview import (
159
+ depth_preview_exists,
160
+ ensure_depth_h264_preview,
161
+ ensure_local_mirror,
162
+ )
163
+
164
+ if depth_preview_exists(video_path):
165
+ continue
166
+ ensure_local_mirror(video_path, wait_seconds=120)
167
+ ensure_depth_h264_preview(video_path)
168
+ except Exception:
169
+ continue
170
+ print(f"[Prewarm] warmed {episode_id}")
171
+ except Exception:
172
+ continue
173
+ finally:
174
+ with _PREWARM_LOCK:
175
+ _PREWARM_RUNNING = False
176
+
177
+
178
+ def _trigger_depth_prewarm(episode_ids: list[str]) -> None:
179
+ """把前 N 个未预热的批次排入队列并启动后台 worker(去重、有界)。"""
180
+ global _PREWARM_RUNNING
181
+ with _PREWARM_LOCK:
182
+ queued = 0
183
+ for episode_id in episode_ids:
184
+ if episode_id in _PREWARM_QUEUED:
185
+ continue
186
+ if queued >= _PREWARM_MAX_PER_PASS:
187
+ break
188
+ if len(_PREWARM_QUEUE) >= _PREWARM_MAX_QUEUE:
189
+ break
190
+ _PREWARM_QUEUED.add(episode_id)
191
+ _PREWARM_QUEUE.append(episode_id)
192
+ queued += 1
193
+ if not queued or _PREWARM_RUNNING:
194
+ return
195
+ _PREWARM_RUNNING = True
196
+ threading.Thread(
197
+ target=_prewarm_worker, name="depth-preview-prewarm",
198
+ daemon=True,
199
+ ).start()
200
+
201
+
202
  @router.get("/episodes")
203
  def episode_list(status: str | None = None, limit: int = 50, offset: int = 0,
204
  brief: bool = False):
 
217
  episodes = [e for e in episodes if e.get("status") == status]
218
  total = len(episodes)
219
  episodes = episodes[offset:offset + limit]
220
+ # 预热深度预览:用户从列表点开前,后台先把预览生成好(尽力而为)
221
+ try:
222
+ _trigger_depth_prewarm([e["id"] for e in episodes])
223
+ except Exception:
224
+ pass
225
  serialize = _ep_to_out_brief if brief else _ep_to_out
226
  return {"episodes": [serialize(e) for e in episodes], "total": total,
227
  "limit": limit, "offset": offset}
 
378
  # 列(hand_pose 等)不含这些关键字,天然排除
379
  if not ("sensor" in lower or "glove" in lower or "tactile" in lower):
380
  continue
381
+ # IMU/valid 列是遥测而非触觉压力阵列,不参与热力图
382
+ if "imu" in lower or lower.endswith("_valid"):
383
+ continue
384
  if sname in available_sensors:
385
  continue
386
  # 真实压力数据(全文件均匀采样)才驱动热力图
 
484
  return label_map.get(role, camera)
485
 
486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  def _detect_depth_sources(batch_dir, ep_id_str: str, master_frame_count: int = 0,
488
  master_fps: float = 0) -> list[dict]:
489
  """Detect canonical metric-depth video streams for the review page.
 
529
  # filename alone must not turn a colour video into 3D input.
530
  if not metric_depth:
531
  continue
532
+ # Warm the local transport mirror in the background while the
533
+ # review page is still mounting; the first full-stream decode
534
+ # then reads from local disk instead of the SSHFS mount.
535
+ try:
536
+ from app.browser_preview import ensure_local_mirror
537
+
538
+ ensure_local_mirror(video_path)
539
+ except Exception:
540
+ pass
541
  frames = 0
542
  fps = float(master_fps or info.get("fps") or 0)
543
  try:
 
575
  f"/api/v1/video/{ep_id_str}/depth-codes/{source_name}/{{frame}}"),
576
  "depth_codes_window_url": (
577
  f"/api/v1/video/{ep_id_str}/depth-codes-window/"
578
+ f"{source_name}?start_frame={{start}}&end_frame={{end}}"
579
+ f"&scale=2"),
580
  "depth_codes_full_url": (
581
+ f"/api/v1/video/{ep_id_str}/depth-codes-full/{source_name}"
582
+ f"?scale=2"),
583
  # Deprecated alias retained for clients that used the old
584
  # frame URL; it now returns raw uint16 codes, never JET.
585
  "depth_preview_url": (
 
698
  result["singles"].append(entry)
699
  result["sources"].append(_as_source(entry, None))
700
 
 
701
  result["sources"].extend(_detect_depth_sources(
702
  batch_dir,
703
  episode_id,
processor/app/routes/video.py CHANGED
@@ -3,6 +3,7 @@
3
  import os
4
  import re
5
  import asyncio
 
6
  import threading
7
  from pathlib import Path
8
  from uuid import UUID, uuid4
@@ -32,6 +33,9 @@ _DEPTH_READER_POOL_LOCK = threading.Lock()
32
  _DEPTH_READER_POOL_MAX = 8
33
  _DEPTH_FRAME_CACHE: dict[tuple[str, int], object] = {}
34
  _DEPTH_FRAME_CACHE_MAX = 48
 
 
 
35
 
36
 
37
  def _read_canonical_depth_codes(path: Path, frame_index: int):
@@ -89,6 +93,39 @@ def _read_canonical_depth_codes(path: Path, frame_index: int):
89
  return result
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def _read_canonical_depth_code_window(path: Path, start_frame: int,
93
  end_frame: int):
94
  """Read a contiguous raw-code window from one sequential decoder.
@@ -122,10 +159,20 @@ def _read_canonical_depth_code_window(path: Path, start_frame: int,
122
  pass
123
 
124
  with state["lock"]:
125
- if start_frame < state["next_frame"]:
 
 
 
 
 
 
 
 
 
126
  state["reader"].close()
127
- state["reader"] = DepthVideoReader(path)
128
- state["next_frame"] = 0
 
129
  frames = []
130
  while state["next_frame"] <= end_frame:
131
  codes = state["reader"].read_codes()
@@ -1152,6 +1199,7 @@ def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1152
  metric_3d_available = True
1153
  valid_hand_frames = 0
1154
  valid_landmark_points = 0
 
1155
  try:
1156
  columns = set(_pd.read_parquet(path, engine="pyarrow").columns)
1157
  field_map = _hand3d_column_map(columns, source_key)
@@ -1181,14 +1229,16 @@ def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1181
 
1182
  # Count real finite landmarks for source selection. This is only
1183
  # metadata for the review UI; it never changes the stored data.
 
 
1184
  count_raw = [field_map.get(name) for name in (
1185
  "hand_0_present", "hand_1_present",
1186
  "hand_0_landmarks_3d", "hand_1_landmarks_3d",
1187
  ) if field_map.get(name)]
1188
- if count_raw:
1189
- count_df = _pd.read_parquet(path, columns=list(dict.fromkeys(count_raw)),
1190
- engine="pyarrow")
1191
- for _, count_row in count_df.iterrows():
1192
  row_has_hand = False
1193
  for hk in ("hand_0", "hand_1"):
1194
  present_name = field_map.get(f"{hk}_present")
@@ -1209,6 +1259,35 @@ def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1209
  row_has_hand = row_has_hand or bool(finite.any())
1210
  if row_has_hand:
1211
  valid_hand_frames += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1212
  except Exception:
1213
  # Metadata failure must not prevent the frame endpoint from working.
1214
  pass
@@ -1231,6 +1310,7 @@ def _hand3d_meta(path: Path, source_key: str | None = None) -> dict:
1231
  "render_video": "",
1232
  "valid_hand_frames": int(valid_hand_frames),
1233
  "valid_landmark_points": int(valid_landmark_points),
 
1234
  "meta_schema_version": 2,
1235
  }
1236
  def _parse_range(range_header: str, file_size: int) -> tuple[int, int]:
@@ -1281,7 +1361,8 @@ async def get_depth_codes(
1281
  raise HTTPException(status_code=404, detail=f"Depth source not found: {name}")
1282
  try:
1283
  codes = await asyncio.to_thread(
1284
- _read_canonical_depth_codes, depth_video, frame_index)
 
1285
  except IndexError:
1286
  raise HTTPException(
1287
  status_code=404,
@@ -1325,6 +1406,124 @@ async def get_depth_jet_lut():
1325
  "values": lut.tolist()})
1326
 
1327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1328
  @router.get("/{episode_id}/depth-preview/{name}/{frame_index}")
1329
  async def get_depth_preview(
1330
  episode_id: str,
@@ -1341,12 +1540,15 @@ async def get_depth_codes_window(
1341
  name: str,
1342
  start_frame: int = Query(0, ge=0),
1343
  end_frame: int | None = Query(None, ge=0),
 
1344
  ):
1345
  """Return a short contiguous window of canonical uint16 depth codes.
1346
 
1347
- This is a transport optimization for the browser-only JET renderer. The
1348
- response is still the exact stored code domain (little-endian uint16),
1349
- never a heatmap or a persisted colorized frame.
 
 
1350
  """
1351
  ep = get_episode(episode_id)
1352
  if ep is None:
@@ -1367,7 +1569,7 @@ async def get_depth_codes_window(
1367
  try:
1368
  frames = await asyncio.to_thread(
1369
  _read_canonical_depth_code_window,
1370
- depth_video, start_frame, requested_end,
1371
  )
1372
  except IndexError:
1373
  raise HTTPException(
@@ -1380,6 +1582,9 @@ async def get_depth_codes_window(
1380
  detail=f"Unable to decode depth codes: {exc}",
1381
  ) from exc
1382
  first = frames[0]
 
 
 
1383
  payload = b"".join(frame.tobytes(order="C") for frame in frames)
1384
  actual_end = start_frame + len(frames) - 1
1385
  return Response(
@@ -1395,6 +1600,7 @@ async def get_depth_codes_window(
1395
  "X-Depth-End": str(actual_end),
1396
  "X-Depth-Frames": str(len(frames)),
1397
  "X-Depth-Frame-Bytes": str(first.nbytes),
 
1398
  "X-Depth-Qmax": "4095",
1399
  },
1400
  )
@@ -1420,12 +1626,14 @@ def _open_depth_code_stream(path: Path):
1420
 
1421
 
1422
  @router.get("/{episode_id}/depth-codes-full/{name}")
1423
- async def get_depth_codes_full(episode_id: str, name: str):
 
1424
  """Stream the complete stored depth-code sequence in frame order.
1425
 
1426
  This is a transport-only optimization for the frontend renderer. The
1427
  payload is raw little-endian uint16 gray12le code data; it is never JET,
1428
- RGB, millimetres, or a persisted preview image.
 
1429
  """
1430
  ep = get_episode(episode_id)
1431
  if ep is None:
@@ -1439,7 +1647,7 @@ async def get_depth_codes_full(episode_id: str, name: str):
1439
  try:
1440
  import numpy as np
1441
  reader, first = await asyncio.to_thread(
1442
- _open_depth_code_stream, depth_video)
1443
  except IndexError:
1444
  raise HTTPException(status_code=404, detail=f"Empty depth source: {name}")
1445
  except (OSError, RuntimeError, ValueError) as exc:
@@ -1448,6 +1656,8 @@ async def get_depth_codes_full(episode_id: str, name: str):
1448
  detail=f"Unable to decode depth codes: {exc}",
1449
  ) from exc
1450
 
 
 
1451
  width, height = int(first.shape[1]), int(first.shape[0])
1452
  frame_bytes = int(first.nbytes)
1453
  expected_frames = int(reader.frame_count or 0)
@@ -1471,6 +1681,7 @@ async def get_depth_codes_full(episode_id: str, name: str):
1471
  "X-Depth-Frame-Bytes": str(frame_bytes),
1472
  "X-Depth-Start": "0",
1473
  "X-Depth-End": str(max(0, expected_frames - 1)),
 
1474
  "X-Depth-Qmax": "4095",
1475
  "X-Depth-Transport": "full-sequential-raw-codes",
1476
  }
@@ -1482,16 +1693,45 @@ async def get_depth_codes_full(episode_id: str, name: str):
1482
  def iterator():
1483
  count = 0
1484
  current = first
 
 
1485
  try:
1486
  while current is not None:
 
 
 
1487
  yield current.tobytes(order="C")
1488
  count += 1
1489
  current = reader.read_codes()
1490
  if current is not None:
1491
  current = np.ascontiguousarray(
1492
  current, dtype="<u2")
 
 
1493
  finally:
1494
  reader.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1495
 
1496
  return StreamingResponse(
1497
  iterator(),
@@ -1705,9 +1945,37 @@ async def _h264_preview_path(source: Path) -> Path:
1705
  raise HTTPException(status_code=503, detail=str(exc)) from exc
1706
 
1707
 
 
 
 
 
 
 
 
 
 
 
 
1708
  def _video_response(file_path: Path, range_header: str | None):
1709
- """Serve an MP4 with byte ranges required by HTML5/Plyr."""
 
 
 
 
 
 
1710
  file_size = file_path.stat().st_size
 
 
 
 
 
 
 
 
 
 
 
1711
 
1712
  def _stream_full():
1713
  with open(file_path, "rb") as f:
@@ -1717,7 +1985,7 @@ def _video_response(file_path: Path, range_header: str | None):
1717
  if not range_header:
1718
  return StreamingResponse(
1719
  _stream_full(), media_type="video/mp4",
1720
- headers={"Content-Length": str(file_size), "Accept-Ranges": "bytes"},
1721
  )
1722
 
1723
  start, end = _parse_range(range_header, file_size)
@@ -1736,9 +2004,9 @@ def _video_response(file_path: Path, range_header: str | None):
1736
 
1737
  return StreamingResponse(
1738
  _stream_range(), status_code=206, media_type="video/mp4",
1739
- headers={"Content-Range": f"bytes {start}-{end}/{file_size}",
1740
- "Content-Length": str(content_length),
1741
- "Accept-Ranges": "bytes"},
1742
  )
1743
 
1744
 
@@ -1894,14 +2162,27 @@ async def stream_browser_depth_preview(
1894
  camera: str,
1895
  range_header: str | None = Header(None, alias="Range"),
1896
  ):
1897
- """Serve a browser-compatible depth stream; colorization stays in Canvas."""
 
 
 
 
 
 
1898
  ep = get_episode(episode_id)
1899
  if ep is None:
1900
  raise HTTPException(status_code=404, detail="Episode not found")
1901
  source = find_depth_video(Path(ep["path"]), camera, str(ep.get("id") or ""))
1902
  if source is None:
1903
  raise HTTPException(status_code=404, detail="Depth video not found")
1904
- preview = await _h264_preview_path(source)
 
 
 
 
 
 
 
1905
  return _video_response(preview, range_header)
1906
 
1907
 
 
3
  import os
4
  import re
5
  import asyncio
6
+ import shutil
7
  import threading
8
  from pathlib import Path
9
  from uuid import UUID, uuid4
 
33
  _DEPTH_READER_POOL_MAX = 8
34
  _DEPTH_FRAME_CACHE: dict[tuple[str, int], object] = {}
35
  _DEPTH_FRAME_CACHE_MAX = 48
36
+ # Sequential decode is cheap, but decoding through more than this many
37
+ # frames to reach a scrub target is not; jump via a seek reader instead.
38
+ _DEPTH_WINDOW_SEEK_GAP = 6
39
 
40
 
41
  def _read_canonical_depth_codes(path: Path, frame_index: int):
 
93
  return result
94
 
95
 
96
+ def _depth_local_source(depth_video: Path) -> Path:
97
+ """Decode from a local mirror of the NAS depth file when one is ready.
98
+
99
+ The mirror is a bit-identical disposable cache (see
100
+ ``browser_preview.ensure_local_mirror``); a missing/cold mirror falls
101
+ back to the authoritative path for this request.
102
+ """
103
+ try:
104
+ from app.browser_preview import ensure_local_mirror
105
+
106
+ return ensure_local_mirror(depth_video)
107
+ except Exception:
108
+ return depth_video
109
+
110
+
111
+ def _downscale_depth_codes(codes, scale: int):
112
+ """Optionally area-downsample raw depth codes for display transport.
113
+
114
+ The browser canvas usually renders the depth tile far below the native
115
+ resolution, so a 2x/4x downsample cuts the payload by 4x/16x without
116
+ visible loss. Codes stay in the canonical uint16 domain so the
117
+ frontend colormap/range pipeline is untouched.
118
+ """
119
+ if not scale or int(scale) <= 1:
120
+ return codes
121
+ import cv2
122
+
123
+ s = int(scale)
124
+ h, w = codes.shape
125
+ return cv2.resize(codes, (max(1, w // s), max(1, h // s)),
126
+ interpolation=cv2.INTER_AREA)
127
+
128
+
129
  def _read_canonical_depth_code_window(path: Path, start_frame: int,
130
  end_frame: int):
131
  """Read a contiguous raw-code window from one sequential decoder.
 
159
  pass
160
 
161
  with state["lock"]:
162
+ gap = start_frame - state["next_frame"]
163
+ if gap < 0 or gap > _DEPTH_WINDOW_SEEK_GAP:
164
+ # Backward or far-forward jump: position a seek reader at the
165
+ # exact target frame instead of decoding from frame 0 (or from a
166
+ # stale pool position). The keyint=2 depth contract keeps the
167
+ # seek cost constant regardless of the jump distance.
168
+ from app.lerobot_v21 import DepthCodeSeekReader
169
+
170
+ fps = float(getattr(state["reader"], "fps", 0) or 30.0)
171
+ pix_fmt = str(getattr(state["reader"], "pix_fmt", "") or "gray12le")
172
  state["reader"].close()
173
+ state["reader"] = DepthCodeSeekReader(
174
+ path, start_frame, fps, pix_fmt)
175
+ state["next_frame"] = start_frame
176
  frames = []
177
  while state["next_frame"] <= end_frame:
178
  codes = state["reader"].read_codes()
 
1199
  metric_3d_available = True
1200
  valid_hand_frames = 0
1201
  valid_landmark_points = 0
1202
+ counted = False # 计数是否真实执行过(前端据此区分"全空"与"统计失败")
1203
  try:
1204
  columns = set(_pd.read_parquet(path, engine="pyarrow").columns)
1205
  field_map = _hand3d_column_map(columns, source_key)
 
1229
 
1230
  # Count real finite landmarks for source selection. This is only
1231
  # metadata for the review UI; it never changes the stored data.
1232
+ # 采样统计:均匀散布读 1/3 的 row group(NAS 上全量读这些列
1233
+ # 实测 ~20s),计数按采样率放大保持量级;采样失败回退全量读。
1234
  count_raw = [field_map.get(name) for name in (
1235
  "hand_0_present", "hand_1_present",
1236
  "hand_0_landmarks_3d", "hand_1_landmarks_3d",
1237
  ) if field_map.get(name)]
1238
+
1239
+ def _count_rows(rows_iter) -> None:
1240
+ nonlocal valid_hand_frames, valid_landmark_points
1241
+ for _, count_row in rows_iter:
1242
  row_has_hand = False
1243
  for hk in ("hand_0", "hand_1"):
1244
  present_name = field_map.get(f"{hk}_present")
 
1259
  row_has_hand = row_has_hand or bool(finite.any())
1260
  if row_has_hand:
1261
  valid_hand_frames += 1
1262
+
1263
+ if count_raw:
1264
+ count_columns = list(dict.fromkeys(count_raw))
1265
+ counted = False
1266
+ try:
1267
+ import pyarrow.parquet as _pq
1268
+
1269
+ parquet = _pq.ParquetFile(path)
1270
+ total_rows = int(parquet.metadata.num_rows)
1271
+ groups = list(range(0, parquet.num_row_groups, 3)) or [0]
1272
+ sampled_rows = sum(
1273
+ int(parquet.metadata.row_group(i).num_rows) for i in groups)
1274
+ sample_factor = (total_rows / max(1, sampled_rows))
1275
+ before_frames = valid_hand_frames
1276
+ before_points = valid_landmark_points
1277
+ for group_index in groups:
1278
+ count_df = parquet.read_row_group(
1279
+ group_index, columns=count_columns).to_pandas()
1280
+ _count_rows(count_df.iterrows())
1281
+ valid_hand_frames = before_frames + int(
1282
+ (valid_hand_frames - before_frames) * sample_factor)
1283
+ valid_landmark_points = before_points + int(
1284
+ (valid_landmark_points - before_points) * sample_factor)
1285
+ counted = True
1286
+ except Exception:
1287
+ count_df = _pd.read_parquet(path, columns=count_columns,
1288
+ engine="pyarrow")
1289
+ _count_rows(count_df.iterrows())
1290
+ counted = True
1291
  except Exception:
1292
  # Metadata failure must not prevent the frame endpoint from working.
1293
  pass
 
1310
  "render_video": "",
1311
  "valid_hand_frames": int(valid_hand_frames),
1312
  "valid_landmark_points": int(valid_landmark_points),
1313
+ "counted": bool(counted),
1314
  "meta_schema_version": 2,
1315
  }
1316
  def _parse_range(range_header: str, file_size: int) -> tuple[int, int]:
 
1361
  raise HTTPException(status_code=404, detail=f"Depth source not found: {name}")
1362
  try:
1363
  codes = await asyncio.to_thread(
1364
+ _read_canonical_depth_codes, _depth_local_source(depth_video),
1365
+ frame_index)
1366
  except IndexError:
1367
  raise HTTPException(
1368
  status_code=404,
 
1406
  "values": lut.tolist()})
1407
 
1408
 
1409
+ @router.get("/depth-color-lut")
1410
+ async def get_depth_color_lut():
1411
+ """Return the 256-entry Google Turbo LUT (bgr order, Rerun-style depth
1412
+ colormap) for frontend-only depth colorization."""
1413
+ from app.lerobot_v21 import turbo_lut_256
1414
+
1415
+ values = [[b, g, r] for (r, g, b) in turbo_lut_256()]
1416
+ return JSONResponse({"name": "google_turbo", "order": "bgr",
1417
+ "values": values})
1418
+
1419
+
1420
+ _DEPTH_STATS_CACHE: dict[str, dict] = {}
1421
+ _DEPTH_STATS_CACHE_LOCK = threading.Lock()
1422
+
1423
+
1424
+ def _depth_stats_for(source: Path) -> dict | None:
1425
+ """q01/q99 bounds for a depth source: memory cache → disk persistence.
1426
+
1427
+ The disk copy is written once by preview generation (fingerprint-keyed)
1428
+ and survives restarts, so the stats endpoint and the preview stream can
1429
+ answer instantly without a second full-stream decode.
1430
+ """
1431
+ try:
1432
+ stats_key = f"{source.resolve()}@{source.stat().st_mtime_ns}"
1433
+ except OSError:
1434
+ return None
1435
+ with _DEPTH_STATS_CACHE_LOCK:
1436
+ cached = _DEPTH_STATS_CACHE.get(stats_key)
1437
+ if cached is not None:
1438
+ return cached
1439
+ try:
1440
+ from app.browser_preview import read_depth_stats
1441
+
1442
+ disk = read_depth_stats(source)
1443
+ if disk is not None:
1444
+ with _DEPTH_STATS_CACHE_LOCK:
1445
+ _DEPTH_STATS_CACHE[stats_key] = disk
1446
+ return disk
1447
+ except Exception:
1448
+ pass
1449
+ return None
1450
+
1451
+
1452
+ def _compute_depth_stats(source: Path) -> dict | None:
1453
+ """Sample-decode the stored depth video and return q01/q99 code bounds.
1454
+
1455
+ One ffmpeg pass keeps ~32 spread frames; the sampled domain is the
1456
+ canonical uint16 gray12le codes, so the percentiles map directly onto the
1457
+ frontend color range (Rerun-style adaptive depth scaling).
1458
+ """
1459
+ import subprocess
1460
+
1461
+ import numpy as np
1462
+ from app.lerobot_v21 import DEPTH_QMAX
1463
+
1464
+ ffprobe = shutil.which("ffprobe")
1465
+ ffmpeg = shutil.which("ffmpeg")
1466
+ if not ffprobe or not ffmpeg:
1467
+ return None
1468
+ probe = subprocess.run(
1469
+ [ffprobe, "-v", "error", "-select_streams", "v:0",
1470
+ "-count_packets", "-show_entries", "stream=nb_read_packets",
1471
+ "-of", "csv=p=0", str(source)],
1472
+ check=False, capture_output=True, text=True, timeout=30)
1473
+ total = 0
1474
+ try:
1475
+ total = int(float(str(probe.stdout).strip().split(",")[0]))
1476
+ except (ValueError, IndexError):
1477
+ total = 0
1478
+ step = max(1, (total or 900) // 32)
1479
+ proc = subprocess.run(
1480
+ [ffmpeg, "-v", "error", "-i", str(source), "-map", "0:v:0",
1481
+ "-vf", f"select=not(mod(n\\,{step}))",
1482
+ "-f", "rawvideo", "-pix_fmt", "gray12le", "-"],
1483
+ check=False, capture_output=True, timeout=600)
1484
+ data = np.frombuffer(proc.stdout, dtype="<u2")
1485
+ if data.size < 2:
1486
+ return None
1487
+ # Keep the same 12-bit code domain as the raw frames served to the
1488
+ # browser. Sampling with gray16le made ffmpeg bit-shift every value
1489
+ # (~x16) to fill the wider format, so q01/q99 came out ~16x too large
1490
+ # and the frontend Turbo ramp collapsed into its darkest segment.
1491
+ data = np.clip(data, 0, DEPTH_QMAX)
1492
+ return {
1493
+ "q01": float(np.percentile(data, 1)),
1494
+ "q99": float(np.percentile(data, 99)),
1495
+ "total_frames": total,
1496
+ }
1497
+
1498
+
1499
+ @router.get("/depth-stats/{episode_id}/{name}")
1500
+ async def get_depth_stats(episode_id: str, name: str):
1501
+ """q01/q99 depth-code bounds for adaptive frontend colorization."""
1502
+ ep = get_episode(episode_id)
1503
+ if ep is None:
1504
+ raise HTTPException(status_code=404, detail="Episode not found")
1505
+ session_dir = Path(ep["path"])
1506
+ safe_name = str(name).replace("/", "_").replace("\\", "_").replace("..", "_")
1507
+ source = find_depth_video(session_dir, safe_name, str(ep.get("id") or ""))
1508
+ if source is None:
1509
+ raise HTTPException(status_code=404, detail=f"Depth source not found: {name}")
1510
+ cached = _depth_stats_for(source)
1511
+ if cached is not None:
1512
+ return JSONResponse(
1513
+ cached,
1514
+ headers={"Cache-Control": "public, max-age=3600"})
1515
+ # No whole-video decode on a cache miss: preview generation computes and
1516
+ # persists these bounds during its own decode, so the frontend receives
1517
+ # the neutral full-range fallback now and re-fetches after the load.
1518
+ from app.lerobot_v21 import DEPTH_QMAX
1519
+
1520
+ return JSONResponse(
1521
+ {"q01": 0.0, "q99": float(DEPTH_QMAX), "total_frames": 0,
1522
+ "pending": True},
1523
+ headers={"Cache-Control": "no-store"},
1524
+ )
1525
+
1526
+
1527
  @router.get("/{episode_id}/depth-preview/{name}/{frame_index}")
1528
  async def get_depth_preview(
1529
  episode_id: str,
 
1540
  name: str,
1541
  start_frame: int = Query(0, ge=0),
1542
  end_frame: int | None = Query(None, ge=0),
1543
+ scale: int = Query(1, ge=1, le=8),
1544
  ):
1545
  """Return a short contiguous window of canonical uint16 depth codes.
1546
 
1547
+ This is a transport optimization for the browser-only colorized renderer.
1548
+ The response is still the exact stored code domain (little-endian
1549
+ uint16), never a heatmap or a persisted colorized frame. ``scale``
1550
+ downsamples each frame by an integer factor (INTER_AREA) to cut the
1551
+ payload; the frontend colormap pipeline is unaffected.
1552
  """
1553
  ep = get_episode(episode_id)
1554
  if ep is None:
 
1569
  try:
1570
  frames = await asyncio.to_thread(
1571
  _read_canonical_depth_code_window,
1572
+ _depth_local_source(depth_video), start_frame, requested_end,
1573
  )
1574
  except IndexError:
1575
  raise HTTPException(
 
1582
  detail=f"Unable to decode depth codes: {exc}",
1583
  ) from exc
1584
  first = frames[0]
1585
+ if int(scale) > 1:
1586
+ frames = [_downscale_depth_codes(frame, scale) for frame in frames]
1587
+ first = frames[0]
1588
  payload = b"".join(frame.tobytes(order="C") for frame in frames)
1589
  actual_end = start_frame + len(frames) - 1
1590
  return Response(
 
1600
  "X-Depth-End": str(actual_end),
1601
  "X-Depth-Frames": str(len(frames)),
1602
  "X-Depth-Frame-Bytes": str(first.nbytes),
1603
+ "X-Depth-Scale": str(scale),
1604
  "X-Depth-Qmax": "4095",
1605
  },
1606
  )
 
1626
 
1627
 
1628
  @router.get("/{episode_id}/depth-codes-full/{name}")
1629
+ async def get_depth_codes_full(episode_id: str, name: str,
1630
+ scale: int = Query(1, ge=1, le=8)):
1631
  """Stream the complete stored depth-code sequence in frame order.
1632
 
1633
  This is a transport-only optimization for the frontend renderer. The
1634
  payload is raw little-endian uint16 gray12le code data; it is never JET,
1635
+ RGB, millimetres, or a persisted preview image. ``scale`` downsamples
1636
+ each frame by an integer factor (INTER_AREA) before streaming.
1637
  """
1638
  ep = get_episode(episode_id)
1639
  if ep is None:
 
1647
  try:
1648
  import numpy as np
1649
  reader, first = await asyncio.to_thread(
1650
+ _open_depth_code_stream, _depth_local_source(depth_video))
1651
  except IndexError:
1652
  raise HTTPException(status_code=404, detail=f"Empty depth source: {name}")
1653
  except (OSError, RuntimeError, ValueError) as exc:
 
1656
  detail=f"Unable to decode depth codes: {exc}",
1657
  ) from exc
1658
 
1659
+ if int(scale) > 1:
1660
+ first = _downscale_depth_codes(first, scale)
1661
  width, height = int(first.shape[1]), int(first.shape[0])
1662
  frame_bytes = int(first.nbytes)
1663
  expected_frames = int(reader.frame_count or 0)
 
1681
  "X-Depth-Frame-Bytes": str(frame_bytes),
1682
  "X-Depth-Start": "0",
1683
  "X-Depth-End": str(max(0, expected_frames - 1)),
1684
+ "X-Depth-Scale": str(scale),
1685
  "X-Depth-Qmax": "4095",
1686
  "X-Depth-Transport": "full-sequential-raw-codes",
1687
  }
 
1693
  def iterator():
1694
  count = 0
1695
  current = first
1696
+ histogram = np.zeros(4096, dtype=np.int64)
1697
+ stats_cache_key = None
1698
  try:
1699
  while current is not None:
1700
+ histogram += np.bincount(
1701
+ current.reshape(-1).astype(np.int64),
1702
+ minlength=4096)[:4096]
1703
  yield current.tobytes(order="C")
1704
  count += 1
1705
  current = reader.read_codes()
1706
  if current is not None:
1707
  current = np.ascontiguousarray(
1708
  current, dtype="<u2")
1709
+ if int(scale) > 1:
1710
+ current = _downscale_depth_codes(current, scale)
1711
  finally:
1712
  reader.close()
1713
+ # Piggyback the q01/q99 display stats on the full-stream pass:
1714
+ # the depth-stats endpoint then needs no second whole-video
1715
+ # decode (which competed with this one and doubled open time).
1716
+ try:
1717
+ stats_cache_key = (
1718
+ f"{depth_video.resolve()}"
1719
+ f"@{depth_video.stat().st_mtime_ns}")
1720
+ cumulative = np.cumsum(histogram)
1721
+ total_pixels = int(cumulative[-1])
1722
+ if total_pixels > 0:
1723
+ q01 = float(np.searchsorted(
1724
+ cumulative, total_pixels * 0.01, side="right"))
1725
+ q99 = float(np.searchsorted(
1726
+ cumulative, total_pixels * 0.99, side="right"))
1727
+ with _DEPTH_STATS_CACHE_LOCK:
1728
+ _DEPTH_STATS_CACHE[stats_cache_key] = {
1729
+ "q01": q01,
1730
+ "q99": q99,
1731
+ "total_frames": int(reader.frame_count or count),
1732
+ }
1733
+ except Exception:
1734
+ pass
1735
 
1736
  return StreamingResponse(
1737
  iterator(),
 
1945
  raise HTTPException(status_code=503, detail=str(exc)) from exc
1946
 
1947
 
1948
+ def _depth_h264_preview_path(source: Path, q01: float, q99: float) -> Path:
1949
+ from app.browser_preview import ensure_depth_h264_preview
1950
+
1951
+ try:
1952
+ return ensure_depth_h264_preview(source, q01=q01, q99=q99)
1953
+ except FileNotFoundError as exc:
1954
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
1955
+ except RuntimeError as exc:
1956
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1957
+
1958
+
1959
  def _video_response(file_path: Path, range_header: str | None):
1960
+ """Serve an MP4 with byte ranges required by HTML5/Plyr.
1961
+
1962
+ Cacheable by the browser (ETag = size+mtime): repeat opens of RGB and
1963
+ depth preview streams are served from the browser cache, so the video
1964
+ element paints its first frame without waiting on a re-download.
1965
+ """
1966
+ file_path = Path(file_path)
1967
  file_size = file_path.stat().st_size
1968
+ try:
1969
+ stat = file_path.stat()
1970
+ etag = f'W/"{stat.st_size:x}-{stat.st_mtime_ns:x}"'
1971
+ except OSError:
1972
+ etag = None
1973
+ common_headers = {
1974
+ "Accept-Ranges": "bytes",
1975
+ "Cache-Control": "public, max-age=3600",
1976
+ }
1977
+ if etag:
1978
+ common_headers["ETag"] = etag
1979
 
1980
  def _stream_full():
1981
  with open(file_path, "rb") as f:
 
1985
  if not range_header:
1986
  return StreamingResponse(
1987
  _stream_full(), media_type="video/mp4",
1988
+ headers={**common_headers, "Content-Length": str(file_size)},
1989
  )
1990
 
1991
  start, end = _parse_range(range_header, file_size)
 
2004
 
2005
  return StreamingResponse(
2006
  _stream_range(), status_code=206, media_type="video/mp4",
2007
+ headers={**common_headers,
2008
+ "Content-Range": f"bytes {start}-{end}/{file_size}",
2009
+ "Content-Length": str(content_length)},
2010
  )
2011
 
2012
 
 
2162
  camera: str,
2163
  range_header: str | None = Header(None, alias="Range"),
2164
  ):
2165
+ """Serve an 8-bit Turbo H.264 preview of the depth stream.
2166
+
2167
+ Generated once per source into the local disposable preview cache
2168
+ (identical clamp/scale/colormap math as the browser renderer used to
2169
+ apply), so the review page plays it through a native <video> element.
2170
+ The canonical 12-bit stream remains the authoritative dataset asset.
2171
+ """
2172
  ep = get_episode(episode_id)
2173
  if ep is None:
2174
  raise HTTPException(status_code=404, detail="Episode not found")
2175
  source = find_depth_video(Path(ep["path"]), camera, str(ep.get("id") or ""))
2176
  if source is None:
2177
  raise HTTPException(status_code=404, detail="Depth video not found")
2178
+ # Prefer the q01/q99 bounds from the in-memory cache or the disk copy
2179
+ # persisted by preview generation; a neutral full-range mapping is used
2180
+ # when the stats have not been computed yet.
2181
+ stats = _depth_stats_for(source) or {}
2182
+ preview = await asyncio.to_thread(
2183
+ _depth_h264_preview_path, source,
2184
+ float(stats.get("q01") or 0.0), float(stats.get("q99") or 4095.0),
2185
+ )
2186
  return _video_response(preview, range_header)
2187
 
2188
 
processor/app/version.py CHANGED
@@ -8,5 +8,5 @@
8
 
9
  前端版本独立管理, 见 web/workflow-studio/package.json 的 version 字段.
10
  """
11
- # 1.6.8 初始管理员改为可配置,公开模板使用占位演示凭据
12
- __version__ = "1.6.8"
 
8
 
9
  前端版本独立管理, 见 web/workflow-studio/package.json 的 version 字段.
10
  """
11
+ # 1.7.0 中英文文档重构(带截图引导图)+ 服务开机自启 + 任务队列修复
12
+ __version__ = "1.7.0"
processor/deploy.py CHANGED
@@ -1,5 +1,5 @@
1
  #!/usr/bin/env python3
2
- """EgoData Data Acquisition 一键部署脚本.
3
 
4
  跨平台 (Linux / Windows),纯标准库实现:
5
 
@@ -558,7 +558,7 @@ def phase_env(ip: str) -> dict:
558
  replace_if=("", "change-me-worker-key"))
559
  # 缺失或等于示例默认值(过期的 IP/端口)时, 补为当前局域网地址
560
  fix("PUBLIC_BASE_URL", f"http://{ip}:{DEFAULT_BACKEND_PORT}",
561
- replace_if=("", "http://127.0.0.1:8000"))
562
  if values.get("STORAGE_BACKEND") == "sftp":
563
  warn(".env 使用 STORAGE_BACKEND=sftp, 本脚本不配置 SSH 隧道/SFTP, 请自行确保可用")
564
 
@@ -1173,7 +1173,7 @@ def phase_report(rep: dict, ports: dict, vllm_enabled: bool, args) -> None:
1173
 
1174
  def parse_args() -> argparse.Namespace:
1175
  p = argparse.ArgumentParser(
1176
- description="EgoData Data Acquisition 一键部署 (跨平台)")
1177
  p.add_argument("--check-only", action="store_true",
1178
  help="只做硬件能力体检, 零副作用")
1179
  p.add_argument("--download-model", action="store_true",
@@ -1200,7 +1200,7 @@ def main() -> None:
1200
  except (OSError, ValueError):
1201
  pass
1202
  say("═" * 62)
1203
- say(f" EgoData Data Acquisition 一键部署")
1204
  say(f" 项目目录: {PROJECT_ROOT}")
1205
  say(f" 系统: {platform.system()} {platform.release()} "
1206
  f"Python: {sys.version.split()[0]}")
 
1
  #!/usr/bin/env python3
2
+ """EgoData processor 一键部署脚本.
3
 
4
  跨平台 (Linux / Windows),纯标准库实现:
5
 
 
558
  replace_if=("", "change-me-worker-key"))
559
  # 缺失或等于示例默认值(过期的 IP/端口)时, 补为当前局域网地址
560
  fix("PUBLIC_BASE_URL", f"http://{ip}:{DEFAULT_BACKEND_PORT}",
561
+ replace_if=("", "http://192.168.110.41:2586"))
562
  if values.get("STORAGE_BACKEND") == "sftp":
563
  warn(".env 使用 STORAGE_BACKEND=sftp, 本脚本不配置 SSH 隧道/SFTP, 请自行确保可用")
564
 
 
1173
 
1174
  def parse_args() -> argparse.Namespace:
1175
  p = argparse.ArgumentParser(
1176
+ description="EgoData processor 一键部署 (跨平台)")
1177
  p.add_argument("--check-only", action="store_true",
1178
  help="只做硬件能力体检, 零副作用")
1179
  p.add_argument("--download-model", action="store_true",
 
1200
  except (OSError, ValueError):
1201
  pass
1202
  say("═" * 62)
1203
+ say(f" EgoData processor 一键部署")
1204
  say(f" 项目目录: {PROJECT_ROOT}")
1205
  say(f" 系统: {platform.system()} {platform.release()} "
1206
  f"Python: {sys.version.split()[0]}")
processor/deploy.sh CHANGED
File without changes
processor/docs/README.md ADDED
@@ -0,0 +1,896 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # processor 项目文档介绍
2
+
3
+ processor 是数据接收、处理、审核和导出系统。
4
+
5
+ 本文档把项目的功能、页面、图片和操作方法整理在一起。阅读时按照页面顺序向下查看,不需要在多个文档之间来回跳转。
6
+
7
+ ## 目录
8
+
9
+ 1. [项目整体流程](#1-项目整体流程)
10
+ 2. [安装和启动](#2-安装和启动)
11
+ 3. [首页](#3-首页)
12
+ 4. [项目和接收数据](#4-项目和接收数据)
13
+ 5. [工作流](#5-工作流)
14
+ 6. [标注数据](#6-标注数据)
15
+ 7. [视频查看和审核](#7-视频查看和审核)
16
+ 8. [已通过和导出](#8-已通过和导出)
17
+ 9. [失败处理](#9-失败处理)
18
+ 10. [垃圾桶](#10-垃圾桶)
19
+ 11. [常见问题](#11-常见问题)
20
+ 12. [数据和程序说明](#12-数据和程序说明)
21
+ 13. [代码结构与功能说明](#13-代码结构与功能说明)
22
+
23
+ ## 1. 项目整体流程
24
+
25
+ 系统的完整流程是:
26
+
27
+ ```text
28
+ 安装启动
29
+ ↓
30
+ 打开首页
31
+ ↓
32
+ 创建或选择项目
33
+ ↓
34
+ 接收数据
35
+ ↓
36
+ 选择并运行工作流
37
+ ↓
38
+ 查看视频和处理结果
39
+ ↓
40
+ 标注、检查和审核
41
+ ↓
42
+ 导出数据
43
+ ```
44
+
45
+ 系统当前的主要页面:
46
+
47
+ - Home:查看整体数据状态;
48
+ - Projects:查看项目和 Episode;
49
+ - Workflow:配置和运行工作流;
50
+ - Annotation:处理标注;
51
+ - Video Review:查看视频和审核数据;
52
+ - Trash:查看已删除的数据。
53
+
54
+ Users 用户系统目前仍在开发中,暂不纳入当前操作流程。
55
+
56
+ ## 2. 安装和启动
57
+
58
+ ### 2.1 下载并打开项目
59
+
60
+ 下载项目后,打开:
61
+
62
+ ```text
63
+ processor/
64
+ ```
65
+
66
+ ### 2.2 启动程序
67
+
68
+ Linux:
69
+
70
+ ```bash
71
+ cd processor
72
+ python deploy.py
73
+ ```
74
+
75
+ 只检查电脑环境,不启动服务:
76
+
77
+ ```bash
78
+ python deploy.py --check-only
79
+ ```
80
+
81
+ 使用外部 AI API 时:
82
+
83
+ ```bash
84
+ python deploy.py --skip-vllm
85
+ ```
86
+
87
+ Windows 用户可以运行项目中的 Windows 部署脚本。
88
+
89
+ ### 2.3 打开网页
90
+
91
+ 等待服务启动后,在浏览器打开管理员提供的地址。
92
+
93
+ 看到登录页面,说明网页已经启动。打开:
94
+
95
+ ```text
96
+ /health
97
+ ```
98
+
99
+ 可以检查服务是否正常。
100
+
101
+ > 图片位置:待补充“启动成功和登录页面”截图。
102
+
103
+ ### 2.4 出现问题
104
+
105
+ - 页面打不开:检查后端是否启动;
106
+ - 数据不处理:检查 Worker 是否启动;
107
+ - 工作流搭建好后,上传对应的数据会自动跑工作流,如果没有自动处理检查一下工作流,或者手动出现跑一下
108
+
109
+ ## 3. 首页
110
+
111
+ ### 3.1 首页有什么作用?
112
+
113
+ 首页用来查看所有数据的总体状态。
114
+
115
+ ### 3.2 首页真实页面
116
+
117
+ ![首页功能图](images/02-home-annotated.png)
118
+
119
+ 图中主要区域:
120
+
121
+ 1. 左侧页面菜单;
122
+ 2. 顶部数据统计;
123
+ 3. 项目列表;
124
+ 4. 最近的数据;
125
+ 5. 刷新按钮。
126
+
127
+ ### 3.3 数据状态
128
+
129
+ | 状态 | 说明 |
130
+ | --------- | ---------------- |
131
+ | Reviewing | 正在检查 |
132
+ | Approved | 已经通过 |
133
+ | Failed | 处理失败 |
134
+ | Trash | 已删除,可以恢复 |
135
+
136
+ ### 3.4 怎么操作?
137
+
138
+ 1. 登录后进入首页;
139
+ 2. 查看顶部数据统计;
140
+ 3. 点击项目名称查看项目;
141
+ 4. 数据没有更新时,点击右上角 Refresh。
142
+
143
+ ### 3.5 看到什么算正常?
144
+
145
+ 数据从 Reviewing 变成 Approved,表示数据检查完成并通过。
146
+
147
+ 如果一直显示 Processing,等待后刷新页面;仍然不变时检查 Worker。
148
+
149
+ ## 4. 项目和接收数据
150
+
151
+ ### 4.1 项目是什么?
152
+
153
+ 项目用于保存一组相关的数据。一个项目下面可以有多个 Episode。
154
+
155
+ processor 负责接收采集端发送的数据,并把数据放入对应项目中。采集设备本身不在 processor 中完成采集。
156
+
157
+ ### 4.2 查看已有项目
158
+
159
+ 打开左侧 `Projects`,可以查看已有项目和接收到的数据。
160
+
161
+ [![项目列表功能](images/projects-list-annotated.png)](images/projects-list-annotated.png)
162
+
163
+ 图中功能:
164
+
165
+ 1. `搜索项目`:输入项目名称,快速找到项目;
166
+ 2. `新建项目`:建立一个新的数据接收项目;
167
+ 3. `项目信息`:查看项目名称、状态、工作流、实际输入、Episode 数量和接收统计;
168
+ 4. `Edit Project`:修改项目名称、工作流和项目状态;
169
+ 5. `Delete`:删除当前项目,删除后可在垃圾桶中查看;
170
+ 6. `展开接收批次`:点击项目行或右侧箭头,查看已接收的批次和 Episode。
171
+
172
+ `active` 表示项目正在使用,可以继续接收数据;`paused` 表示项目暂停使用。
173
+
174
+ ### 4.3 新建项目
175
+
176
+ 点击右上角 `New Project`,填写项目基本信息。
177
+
178
+ [![新建项目功能](images/project-new-annotated.png)](images/project-new-annotated.png)
179
+
180
+ 各项内容:
181
+
182
+ - `Project Name`:项目名称;
183
+ - `Workflow`:给项目绑定一个当前工作流,可以新建工作流,新建的工作流初始为空,也可以选择已有的工作流;
184
+ - `Status`:`active` 表示启用,`paused` 表示暂停;
185
+ - `Target Episodes`:计划接收的数据数量,`0` 表示不限制;
186
+ - `Description`:项目说明,可以不填写;
187
+ - `Save`:保存项目。
188
+
189
+ 项目名称不能为空。保存新项目后,系统会询问是否进入工作流页面继续设置。
190
+
191
+ ### 4.4 选择工作流
192
+
193
+ 在新建项目页面的 `Workflow` 中选择对应工作流。
194
+
195
+ [![选择项目工作流](images/project-workflow-select-annotated.png)](images/project-workflow-select-annotated.png)
196
+
197
+ 工作流列表功能:
198
+
199
+ 1. 点击 `Workflow` 输入框打开工作流列表;
200
+ 2. 点击 `+ New Workflow` 新建空白工作流;
201
+ 3. 带 `Template` 标记的是模板工作流;
202
+ 4. 点击已有工作流名称,将它绑定到当前项目;
203
+ 5. 点击右侧按钮,进入工作流页面编辑该工作流。
204
+
205
+ 一个项目使用一个当前工作流。重新选择其他工作流时会另存为新的绑定当前的工作流。
206
+
207
+ 选择原则:
208
+
209
+ - RGB 数据选择 RGB 相关工作流;
210
+ - RGB 加 Depth 选择 RGB-D 相关工作流;
211
+ - 有手套传感器时选择包含 Glove Sensor 的工作流;
212
+ - 不确定时不要随便选择,先确认项目输入数据类型。
213
+
214
+ ### 4.5 接收数据流程
215
+
216
+ ```text
217
+ 采集端生成数据
218
+ ↓
219
+ 采集端发送数据
220
+ ↓
221
+ processor 接收数据
222
+ ↓
223
+ 解压和检查
224
+ ↓
225
+ 匹配项目和工作流
226
+ ↓
227
+ 项目中出现新的 Episode
228
+ ```
229
+
230
+ ### 4.6 接收数据时怎么操作?
231
+
232
+ 1. 新建项目并选择正确的工作流;
233
+ 2. 保持项目状态为 `active`;
234
+ 3. 由采集端向 processor 发送数据;
235
+ 4. 等待 processor 接收、解压和检查;
236
+ 5. 展开项目,确认新的批次和 Episode 已经出现。
237
+
238
+ 项目已经绑定工作流时,系统会处理新接收的数据。没有绑定工作流时,数据只会被接收和保存,不会执行后处理;之后绑定工作流并保存,系统会补充处理已有批次。
239
+
240
+ ### 4.7 重要提醒
241
+
242
+ - 接收过程中不要关闭采集端的发送程序;
243
+ - 解压和检查完成前不要重复发送同一个数据;
244
+ - 每个 Episode 应该有对应的视频或传感器数据;
245
+ - 接收失败时先查看失败提示,再重新发送或重新处理;
246
+ - 项目和工作流不匹配时,不要强行运行。
247
+
248
+ ## 5. 工作流
249
+
250
+ ### 5.1 工作流是什么?
251
+
252
+ 工作流决定系统如何处理接收到的数据。
253
+
254
+ ```text
255
+ 输入数据 → 处理数据 → 检查结果 → 导出数据
256
+ ```
257
+
258
+ ### 5.2 工作流页面
259
+
260
+ 点击左侧 `Workflow` 进入工作流页面。
261
+
262
+ [![工作流页面](images/workflow-empty-annotated.png)](images/workflow-empty-annotated.png)
263
+
264
+ 图中功能:
265
+
266
+ 1. `工作流选择`:切换已有工作流或新建工作流;
267
+ 2. `工作流名称`:查看或修改当前工作流名称;
268
+ 3. `编辑画布`:放置节点并用连接线组成处理流程;
269
+ 4. `节点列表`:提供输入、处理、审核和导出节点;
270
+ 5. `Template`:从模板开始建立工作流;
271
+ 6. `Save`:保存当前工作流。
272
+
273
+ ### 5.3 从模板开始
274
+
275
+ 点击右上角 `Template`,选择和接收数据匹配的模板。
276
+
277
+ [![选择工作流模板](images/workflow-template-annotated.png)](images/workflow-template-annotated.png)
278
+
279
+ 1. `RGB-D_Workflow`:使用 RGB 和深度数据的处理链;
280
+ 2. `Stereo-RGB_Workflow`:使用左、右两路 RGB 数据的处理链;
281
+ 3. `Cancel`:不使用模板,关闭选择窗口。
282
+
283
+ 新建或已有工作流都可以套用模板。选择模板会替换当前画布中的处理链,但不会改变当前工作流的名称。
284
+
285
+ 模板只提供处理步骤。输入设备节点会根据项目实际接收的数据生成。画布中已经有未保存修改时,先保存再更换模板。
286
+
287
+ ### 5.4 检查完整流程
288
+
289
+ 下面是已经连接好的工作流示例。
290
+
291
+ [![完整工作流](images/workflow-editor-annotated.png)](images/workflow-editor-annotated.png)
292
+
293
+ 1. `Stereo RGB Camera`:项目接收到的左、右 RGB 视频;
294
+ 2. `Glove Sensor`:项目接收到的手套传感器数据;
295
+ 3. `AI Annotation`:根据 RGB 视频生成任务文字和标注片段;
296
+ 4. `Human Review`:把视频、传感器和标注结果交给人工审核;
297
+ 5. `LeRobot Export`:按工作流设置准备导出数据;
298
+ 6. `Save`:确认连接正确后保存工作流。
299
+
300
+ 图中的视频、手套数据和 AI 标注会一起进入人工审核:
301
+
302
+ ```text
303
+ Stereo RGB Camera ─┬→ AI Annotation ─┐
304
+ └─────────────┤
305
+ Glove Sensor ───────────────────┤
306
+ ↓
307
+ Human Review
308
+ ↓
309
+ LeRobot Export
310
+ ```
311
+
312
+ ### 5.5 AI 标注设置
313
+
314
+ 打开 `AI Annotation` 节点的设置窗口,选择标注语言和 API。
315
+
316
+ [![AI 标注设置](images/workflow-ai-settings-annotated.png)](images/workflow-ai-settings-annotated.png)
317
+
318
+ 1. `Label Language`:选择中文或英文标注;
319
+ 2. `Saved API Profile`:选择已经保存的 API 方案;
320
+ 3. `API Provider`:选择 API 厂商;
321
+ 4. `API Model`:填写或选择使用的模型;
322
+ 5. `API Key`:���写 API 密钥;
323
+ 6. `API Base URL`:填写 API 地址,使用官方默认地址时可以留空;
324
+ 7. `Test Connection`:保存前测试 API 是否可用;
325
+ 8. `Save`:保存 AI 标注设置。
326
+
327
+ > API 密钥应显示为隐藏状态。不要分享显示完整密钥的截图。
328
+
329
+ ### 5.6 工作流模块说明
330
+
331
+ 右侧模块分为 `INPUT`、`PROCESS`、`REVIEW` 和 `EXPORT` 四组。把模块拖到画布中,再按照输入和输出连接。
332
+
333
+ #### INPUT:输入数据
334
+
335
+ | 模块 | 接收的数据 | 提供的数据 | 什么时候使用 |
336
+ | ------------------- | ---------------------- | -------------------------------------- | ------------------------------------------------- |
337
+ | Glove Sensor | 手套压力和关节数据 | Glove Sensor Data | 项目中有手套传感器时使用 |
338
+ | RGB Camera | 单路彩色视频 | RGB Video | 单目普通相机、单目鱼眼相机或 RGB-D 相机的彩色画面 |
339
+ | RGB-D Camera | 彩色视频和真实深度流 | RGB Video、Depth | 单个 RGB-D 相机,有深度数据 |
340
+ | Stereo RGB Camera | 左、右两路彩色视频 | Left RGB Video、Right RGB Video | 只有双目 RGB 视频时使用,不会直接提供真实深度 |
341
+ | Stereo RGB-D Camera | 左、右彩色视频和深度流 | Left RGB Video、Right RGB Video、Depth | 双目视频同时带有真实深度时使用 |
342
+
343
+ #### PROCESS:处理数据
344
+
345
+ | 模块 | 需要什么 | 产生什么 | 作用 |
346
+ | -------------------- | ----------------- | ---------- | ------------------------------------------ |
347
+ | Human Annotation | RGB Video | Annotation | 由人工建立和修改任务片段 |
348
+ | AI Annotation | RGB Video | Annotation | 使用本地模型或 API 自动生成中文、英文标注 |
349
+ | RGB_TO_2D_BareHand | RGB Video | Hand 2D | 识别裸手的视频中二维关键点 |
350
+ | RGB_TO_2D_BlackGlove | RGB Video | Hand 2D | 识别黑手套的视频中二维关键点 |
351
+ | RGB-D_3D_BareHand | RGB Video + Depth | Hand 3D | 使用匹配的深度数据计算裸手真实三维关键点 |
352
+ | RGB-D_3D_BlackGlove | RGB Video + Depth | Hand 3D | 使用匹配的深度数据计算黑手套真实三维关键点 |
353
+
354
+ `RGB_TO_2D` 模块产生的是二维关键点。页面中的空间预览只是帮助查看,不作为真实米制 3D 数据导出。
355
+
356
+ #### REVIEW:检查数据
357
+
358
+ | 模块 | 作用 | 结果 |
359
+ | ----------------- | ----------------------------------------------------------------- | -------------------------------------------------- |
360
+ | Human Review | 由用户播放视频,检查关键点、深度、传感器和标注 | 审核通过后输出 Reviewed Data |
361
+ | AI Quality Review | 自动检查视频能否解码、帧是否连续、是否黑屏或卡帧、AI 标注是否完整 | 检查通过后输出 Reviewed Data,异常数据进入人工处理 |
362
+
363
+ #### EXPORT:导出数据
364
+
365
+ | 模块 | 作用 | 主要设置 |
366
+ | -------------- | ---------------------- | -------------------------------------------------- |
367
+ | LeRobot Export | 导出 LeRobot 数据集 | 选择 v2.1 或 v3.0;包含对应的 meta、data 和 videos |
368
+ | HDF5 Export | 导出一个 HDF5 数据文件 | 使用 gzip 压缩保存 |
369
+
370
+ 导出模块应连接在审核模块之后。这样只有检查通过的数据才会进入导出流程。
371
+
372
+ ### 5.7 常见处理方式
373
+
374
+ 普通 RGB:
375
+
376
+ ```text
377
+ RGB → Hand 2D → Human Review → Export
378
+ ```
379
+
380
+ RGB 和深度:
381
+
382
+ ```text
383
+ RGB + Depth → Hand 3D → Human Review → Export
384
+ ```
385
+
386
+ RGB 和手套传感器:
387
+
388
+ ```text
389
+ RGB + Glove Sensor → Human Review → Export
390
+ ```
391
+
392
+ ### 5.8 重要规则
393
+
394
+ - 有 RGB 和 Depth,才能计算真实 3D;
395
+ - 只有 RGB 时,可以得到 2D 关键点;
396
+ - 只有 RGB 时显示的空间效果不等于真实 metric 3D;
397
+ - 输入不匹配时,对应步骤会被跳过。
398
+
399
+ 看到 Input mismatch,说明工作流需要的数据和当前项目数据不匹配,需要换用正确的工作流。
400
+
401
+ ## 6. 标注数据
402
+
403
+ ### 6.1 选择需要标注的数据
404
+
405
+ 点击左侧 `Annotation`。先在右侧展开项目,再选择一个 Episode。
406
+
407
+ [![标注数据列表](images/annotation-list-annotated.png)](images/annotation-list-annotated.png)
408
+
409
+ 1. `Annotation`:进入标注页面;
410
+ 2. 点击项目名称展开项目;
411
+ 3. 点击 Episode 编号,加载视频和已有标注。
412
+
413
+ 中间显示 `Select an episode to review` 时,表示还没���选择 Episode,不是系统故障。
414
+
415
+ ### 6.2 查看和编辑标注
416
+
417
+ [![标注编辑页面](images/annotation-editor-annotated.png)](images/annotation-editor-annotated.png)
418
+
419
+ 1. `Preview Options`:控制关键点、轨迹、深度和 3D 预览;
420
+ 2. `RGB 和 2D 关键点`:查看原始画面和图片上的手部关键点;
421
+ 3. `3D 手部空间`:查看空间中的手部关键点;
422
+ 4. `深度伪彩色预览`:查看深度数据的网页显示效果;
423
+ 5. `AI Annotate`:按工作流设置运行 AI 标注;
424
+ 6. `标注片段`:点击右侧某个片段,查看或修改它;
425
+ 7. `Set Start / Set End`:用当前帧设置片段的开始帧和结束帧;
426
+ 8. `Save Changes`:保存片段名称和帧范围的修改。
427
+
428
+ ### 6.3 人工修改标注
429
+
430
+ 1. 在右侧点击需要修改的标注片段;
431
+ 2. 检查开始帧、结束帧和任务名称;
432
+ 3. 修改后点击 `Save Changes`;
433
+ 4. 检查下方时间条中的标注块。
434
+
435
+ ### 6.4 AI 标注
436
+
437
+ 1. 确认工作流中已经设置 `AI Annotation`;
438
+ 2. 按工作流设置选择中文或英文;
439
+ 3. 点击 `AI Annotate`;
440
+ 4. 等待标注片段生成;
441
+ 5. 人工检查并修改不正确的内容。
442
+ 6. 工作流中有这个功能会自动进行标注
443
+ 7. 可能会因为网络波动导致一个小片段,没有对应的标注信息
444
+
445
+ ## 7. 视频查看和审核
446
+
447
+ ### 7.1 选择待审核数据
448
+
449
+ 点击 `Video Review` 下的 `Reviewing`,在右侧选择一个待审核 Episode。
450
+
451
+ [![待审核数据列表](images/reviewing-list-annotated.png)](images/reviewing-list-annotated.png)
452
+
453
+ 1. `Reviewing`:查看等待人工审核的数据;
454
+ 2. `展开项目`:查看项目下的 Episode;
455
+ 3. `选择 Episode`:打开数据,并查看帧率、帧数、相机和处理状态;
456
+ 4. `Approve`:检查正确后,通过当前 Episode;
457
+ 5. `Reprocess`:处理结果不正确时,重新运行项目绑定的工作流。
458
+
459
+ ### 7.2 检查视频和处理结果
460
+
461
+ [![视频审核页面](images/reviewing-player-annotated.png)](images/reviewing-player-annotated.png)
462
+
463
+ 1. `Preview Options`:显示或隐藏不同的预览内容;
464
+ 2. `RGB 原图`:查看彩色视频;打开关键点显示后会叠加 2D 手部关键点;
465
+ 3. `3D 手部空间`:检查空间中的手部关键点;
466
+ 4. `深度伪彩色预览`:检查深度数据是否正常;
467
+ 5. `只读标注片段`:检查任务名称和帧范围;
468
+ 6. `播放和逐帧控制`:播放视频,或使用上一帧、下一帧检查细节;
469
+ 7. `Approve`:确认当前 Episode 审核通过。
470
+
471
+ 审核时先等待关联数据加载完成,再点击播放。RGB、2D、3D、深度和传感器画面应使用相同帧号显示。
472
+
473
+ ### 7.3 深度画面说明
474
+
475
+ 深度画面中的蓝色、绿色、黄色等颜色只是网页中的预览效果。
476
+
477
+ 深度视频保存的是原始 12-bit 深度码,也不会把伪彩色图片写回原始数据。
478
+
479
+ ### 7.4 常见显示问题
480
+
481
+ - 黑屏:等待当前 Episode 加载完成,再刷新页面;
482
+ - 没有 3D 手部:检查工作流是否包含 3D 处理,以及 Episode 是否有 Depth;
483
+ - 关键点不动:重新处理对应 Episode;
484
+ - 播放卡顿:等待关联数据完成缓存后再播放。
485
+
486
+ ## 8. 已通过和导出
487
+
488
+ ### 8.1 已通过数据
489
+
490
+ 点击 `Video Review` 下的 `Approved`,可以查看已经通过审核的数据。
491
+
492
+ [![已通过和导出](images/approved-list-annotated.png)](images/approved-list-annotated.png)
493
+
494
+ 1. `Approved`:进入已通过列表;
495
+ 2. `多选模式`:点击页面顶部 `Select` 进入;进入后按钮变为 `Cancel`;
496
+ 3. `Select All`:选择当前列表中的全部 Episode;
497
+ 4. `Episode 复选框`:可以单选一条,也可以同时选择多条;
498
+ 5. `Export`:导出这一条 Episode;
499
+ 6. `Unreview`:取消审核通过状态,让数据回到待审核列表。
500
+
501
+ ### 8.2 导出流程
502
+
503
+ 导出一条 Episode:
504
+
505
+ 1. 在该 Episode 的项目卡片中点击 `Export`;
506
+ 2. 等待导出完成并下载压缩包。
507
+
508
+ 批量导出:
509
+
510
+ 1. 点击页面顶部 `Select`;
511
+ 2. 勾选一条或多条 Episode,也可以点击 `Select All`;
512
+ 3. 选择后页面会显示 `Batch Download` 操作条;
513
+ 4. 点击 `Batch Download`,等待导出完成。
514
+
515
+ 导出格式使用当前工作流导出节点中的设置,例如 LeRobot 2.1、LeRobot 3.0 或 HDF5,不需要进入独立导出页面。
516
+
517
+ ### 8.3 导出内容
518
+
519
+ ```text
520
+ 项目名称/
521
+ ├── meta/ # 说明和统计信息
522
+ ├── data/ # 每帧数据、关键点和传感器数据
523
+ └── videos/ # 视频
524
+ ```
525
+
526
+ 后处理后的关键点、标注和手套传感器数据,会和对应的数据一起导出。
527
+
528
+ ## 9. 失败处理
529
+
530
+ ### 9.1 处理流程
531
+
532
+ ```text
533
+ 看到 Failed
534
+ ↓
535
+ 打开失败数据
536
+ ↓
537
+ 查看错误提示
538
+ ↓
539
+ 点击 Retry 或 Reprocess
540
+ ↓
541
+ 等待重新处理
542
+ ```
543
+
544
+ > 图片位置:待补充“Failed 失败页面”截图。
545
+
546
+ ### 9.2 常见错误
547
+
548
+ | 错误 | 处理方法 |
549
+ | ------------- | ---------------------------- |
550
+ | 没有 RGB | 检查是否��传了彩色视频 |
551
+ | 没有 Depth | 不能计算真实 3D |
552
+ | 工作流不匹配 | 选择正确的工作流 |
553
+ | AI 标注失败 | 检查 API 设置后重试 |
554
+ | 视频打不开 | 由采集端重新发送或联系管理员 |
555
+ | Worker 未运行 | 联系管理员重启服务 |
556
+
557
+ 重试两次仍然失败时,请发送数据名称、错误截图、失败时间和操作步骤。
558
+
559
+ ## 10. 垃圾桶
560
+
561
+ ### 10.1 垃圾桶有什么作用?
562
+
563
+ 删除的数据会先进入垃圾桶。
564
+
565
+ ### 10.2 怎么操作?
566
+
567
+ [![垃圾桶页面](images/trash-annotated.png)](images/trash-annotated.png)
568
+
569
+ 1. 点击左侧 `Trash` 进入垃圾桶;
570
+ 2. 查看数据的剩余保留时间;
571
+ 3. 需要保留时点击 `Restore` 恢复;
572
+ 4. 确定不再需要时点击 `Delete` 永久删除;
573
+ 5. `Purge All` 会永久删除垃圾桶中的全部数据。
574
+
575
+ ### 10.3 重要提醒
576
+
577
+ - 垃圾桶中的数据可以恢复;
578
+ - 永久删除后通常不能恢复;
579
+ - 清空垃圾桶前要再次确认;
580
+ - 找不到数据时,先检查项目名称和删除时间。
581
+
582
+ ## 11. 常见问题
583
+
584
+ ### 页面打不开
585
+
586
+ 检查服务是否启动。
587
+
588
+ ### 数据一直处理中
589
+
590
+ 刷新页面。如果长时间不变,可能是 Worker 没有运行。
591
+
592
+ ### 深度画面是黑色
593
+
594
+ 等待加载完成后刷新,并检查当前 Episode 是否真的有深度视频。
595
+
596
+ ### 只有 2D,没有 3D
597
+
598
+ 真实 3D 需要 RGB 和 Depth 两种输入。只有 RGB 时不能计算真实空间位置。
599
+
600
+ ### 播放卡顿
601
+
602
+ 等待数据加载完成后再播放。检查细节时可以使用上一帧和下一帧。
603
+
604
+ ### AI 标注没有结果
605
+
606
+ 检查 API 设置、模型、网络和语言设置,然后重新运行 AI 标注。
607
+
608
+ ### 导出失败
609
+
610
+ 确认数据已经审核完成,再重新导出。
611
+
612
+ ## 12. 数据和程序说明
613
+
614
+ ### 12.1 项目目录
615
+
616
+ ```text
617
+ processor/
618
+ ├── app/ # 后端、工作流、视频和导出
619
+ ├── worker/ # 后台处理任务
620
+ ├── web/ # 页面和前端代码
621
+ ├── scripts/ # 启动、检查和维护脚本
622
+ ├── tests/ # 自动测试
623
+ ├── deploy.py # 一键部署入口
624
+ └── .env # 本机配置,不要提交到 Git
625
+ ```
626
+
627
+ ### 12.2 项目数据目录
628
+
629
+ ```text
630
+ data/sessions/<project>/
631
+ ├── data/
632
+ ├── meta/
633
+ └── videos/
634
+ ```
635
+
636
+ 原始深度视频保存为深度码。浏览器显示的伪彩色不会写回原始数据。
637
+
638
+ ### 12.3 启动检查
639
+
640
+ ```bash
641
+ python deploy.py --check-only
642
+ python -m compileall app worker
643
+ PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q
644
+ ```
645
+
646
+ 不要把 .env、API Key、Token、真实数据和服务器凭据提交到 Git。
647
+
648
+ ## 13. 代码结构与功能说明
649
+
650
+ 本章用于说明页面背后的前端、后端、Worker 和数据处理代码。普通操作不需要修改这些文件;需要维护功能时,可以根据本章快速找到对应位置。
651
+
652
+ ### 13.1 系统代码链路
653
+
654
+ 浏览器页面
655
+ ↓
656
+ HTML 模板和 JavaScript
657
+ ↓
658
+ FastAPI 后端接口
659
+ ↓
660
+ 项目、Episode、标注和工作流
661
+ ↓
662
+ Worker 领取处理任务
663
+ ↓
664
+ 工作流模块执行后处理
665
+ ↓
666
+ 保存关键点、3D、传感器和标注结果
667
+ ↓
668
+ 视频审核和数据导出
669
+
670
+ 业务数据主要保存在项目文件中。用户账号可以使用数据库;数据库暂时不可用时,程序保留兼容回退能力。
671
+
672
+ ### 13.2 代码目录树
673
+
674
+ processor/
675
+ ├── app/ # Python 后端
676
+ │ ├── main.py # FastAPI 启动入口
677
+ │ ├── config.py # 环境变量和系统配置
678
+ │ ├── paths.py # 统一目录路径
679
+ │ ├── localstore.py # 项目、工作流和 Episode 文件状态
680
+ │ ├── database.py # 用户账号数据库连接
681
+ │ ├── models.py # 数据库模型
682
+ │ ├── workflow_dispatch.py # 工作流匹配、自动派发和历史回填
683
+ │ ├── workflow_bindings.py # 工作流与实际设备输入绑定
684
+ │ ├── ai_annotation.py # AI 标注服务
685
+ │ ├── export_engine.py # 导出任务公共逻辑
686
+ │ ├── lerobot_export.py # LeRobot 3.0 数据生成
687
+ │ ├── lerobot_v21.py # LeRobot 2.1 兼容处理
688
+ │ ├── hdf5_export.py # HDF5 数据生成
689
+ │ ├── routes/ # 页面和业务接口
690
+ │ ├── api/ # 项目、工作流和 Worker 接口
691
+ │ └── processing/ # 工作流后处理框架
692
+ │ ├── registry.py # 自动注册处理模块
693
+ │ ├── catalog.py # 向工作流页面提供模块清单
694
+ │ ├── batch.py # 查找批次中的视频和 Parquet
695
+ │ └── modules/ # 每个工作流节点的执行代码
696
+ ├── worker/ # 后台处理程序
697
+ │ ├── __main__.py # Worker 启动入口
698
+ │ ├── runner.py # 领取和执行工作流任务
699
+ │ └── client.py # 与后端 Worker API 通信
700
+ ├── web/ # 前端页面
701
+ │ ├── templates/ # HTML 页面模板
702
+ │ ├── static/js/ # 页面交互、播放和渲染
703
+ │ └── workflow-studio/ # React 工作流编辑器源码
704
+ ├── scripts/ # 部署、迁移和维护脚本
705
+ ├── tests/ # 自动测试
706
+ ├── models/ # 本地模型文件
707
+ ├── docs/ # 中文和英文项目文档
708
+ ├── deploy.py # 一键部署入口
709
+ └── requirements*.txt # Python 依赖
710
+
711
+ <code>.venv*</code>、<code>.pytest_cache</code> 和 <code>.backups</code> 是本机环境、测试缓存或备份,不属于主要业务代码。
712
+
713
+ ### 13.3 前端代码
714
+
715
+ 前端由两部分组成:
716
+
717
+ 1. 普通页面使用 HTML 模板和原生 JavaScript;
718
+ 2. 工作流编辑器使用 React 和 TypeScript。
719
+
720
+ #### 页面模板
721
+
722
+ | 文件 | 作用 |
723
+ | --- | --- |
724
+ | <code>web/templates/base.html</code> | 公共页面框架、左侧菜单和通用资源 |
725
+ | <code>web/templates/overview.html</code> | 首页 |
726
+ | <code>web/templates/tasks.html</code> | 项目管理页面 |
727
+ | <code>web/templates/index.html</code> | 标注和视频审核页面 |
728
+ | <code>web/templates/trash.html</code> | 垃圾桶页面 |
729
+ | <code>web/templates/login.html</code> | 登录页面 |
730
+ | <code>web/templates/users.html</code> | 用户管理页面,目前仍在开发 |
731
+ | <code>web/templates/workflow_studio.html</code> | 工作流编辑器入口 |
732
+
733
+ #### 页面 JavaScript
734
+
735
+ | 文件 | 作用 |
736
+ | --- | --- |
737
+ | <code>web/static/js/dashboard.js</code> | 首页统计、最近数据和刷新 |
738
+ | <code>web/static/js/tasks.js</code> | 项目创建、编辑、删除和批次展开 |
739
+ | <code>web/static/js/app.js</code> | Episode 列表、审核、删除和导出操作 |
740
+ | <code>web/static/js/player.js</code> | 视频加载、统一播放时钟和帧同步 |
741
+ | <code>web/static/js/annotations.js</code> | 标注片段、时间条和逐帧标注 |
742
+ | <code>web/static/js/slice-preview.js</code> | 右上角标注片段预览 |
743
+ | <code>web/static/js/hand-overlay.js</code> | 在 RGB 原图上绘制 2D 手部关键点 |
744
+ | <code>web/static/js/depth-renderer.js</code> | 将原始 12-bit 深度码渲染为网页伪彩色 |
745
+ | <code>web/static/js/heatmap.js</code> | 手套传感器数据同步显示 |
746
+ | <code>web/static/js/media-cache.js</code> | 使用 IndexedDB 缓存关键点和传感器数据 |
747
+ | <code>web/static/js/i18n.js</code> | 中英文界面文字 |
748
+ | <code>web/static/js/login.js</code> | 登录表单 |
749
+ | <code>web/static/js/users.js</code> | 用户管理交互,目前仍在开发 |
750
+
751
+ 工作流编辑器源码位于 <code>web/workflow-studio/src/</code>。编译后的文件位于 <code>web/static/workflow-studio/</code>,修改工作流页面时应修改源码并重新构建,不要直接修改编译结果。
752
+
753
+ ### 13.4 后端入口和页面接口
754
+
755
+ <code>app/main.py</code> 是后端入口,主要负责:
756
+
757
+ - 启动 FastAPI;
758
+ - 初始化文件目录和用户数据库;
759
+ - 预热项目与 Episode 元数据缓存;
760
+ - 启动上传处理队列;
761
+ - 注册页面、项目、视频、标注、工作流和导出接口;
762
+ - 提供静态文件缓存和大媒体文件传输规则。
763
+
764
+ | 文件 | 作用 |
765
+ | --- | --- |
766
+ | <code>app/routes/pages.py</code> | 返回首页、项目、审核、工作流和垃圾桶页面 |
767
+ | <code>app/routes/dashboard.py</code> | 首页统计、最近 Episode 和趋势 |
768
+ | <code>app/routes/session.py</code> | 接收压缩包、解压、整理目录和导入元数据 |
769
+ | <code>app/routes/ingestion.py</code> | Episode 列表、详情、审核、重跑、删除和恢复 |
770
+ | <code>app/routes/video.py</code> | RGB 视频、深度码、深度预览、2D、3D 和传感器接口 |
771
+ | <code>app/routes/annotations.py</code> | 创建、修改、删除和逐帧读取标注 |
772
+ | <code>app/routes/export.py</code> | 单个导出、批量导出、任务状态和下载 |
773
+ | <code>app/routes/auth.py</code> | 登录、退出和当前用户 |
774
+ | <code>app/routes/devices.py</code> | 采集设备心跳和输入能力 |
775
+
776
+ ### 13.5 项目、工作流和 Worker API
777
+
778
+ | 文件 | 作用 |
779
+ | --- | --- |
780
+ | <code>app/api/projects.py</code> | 项目创建、编辑、删除、输入源和工作流绑定 |
781
+ | <code>app/api/workflows.py</code> | 工作流创建、保存、运行、使用情况和重试 |
782
+ | <code>app/api/worker.py</code> | Worker 领取任务、下载输入、心跳、完成和失败上报 |
783
+ | <code>app/api/exceptions.py</code> | 汇总和清理处理异常 |
784
+ | <code>app/api/users.py</code> | 用户创建、��色、状态和删除,目前仍在开发 |
785
+
786
+ 后端与 Worker 的任务过程:
787
+
788
+ 后端创建运行记录
789
+ ↓
790
+ Worker 领取任务
791
+ ↓
792
+ Worker 下载 Episode 输入
793
+ ↓
794
+ 执行工作流节点
795
+ ↓
796
+ Worker 上传结果并报告完成
797
+ ↓
798
+ Episode 进入审核或通过状态
799
+
800
+ Worker 会定期发送心跳。如果 Worker 中断,任务不会立刻丢失,后端可以在超时后重新安排。
801
+
802
+ ### 13.6 工作流后处理模块
803
+
804
+ <code>app/processing/modules/</code> 中一个 Python 文件通常对应一个工作流节点。模块通过注册表自动出现在工作流编辑器中,并由 Worker 执行。
805
+
806
+ | 模块文件 | 作用 |
807
+ | --- | --- |
808
+ | <code>mono_camera.py</code> | 单路 RGB 输入 |
809
+ | <code>rgbd_camera.py</code> | RGB 和深度输入 |
810
+ | <code>stereo_camera.py</code> | 左、右双目 RGB 输入 |
811
+ | <code>stereo_rgbd_camera.py</code> | 左、右 RGB 和深度输入 |
812
+ | <code>glove_sensor.py</code> | 手套传感器输入 |
813
+ | <code>mediapipe_hand.py</code> | MediaPipe 手部关键点和手势识别 |
814
+ | <code>rgb_hand_3d.py</code> | 裸手 RGB 二维关键点和预览空间效果 |
815
+ | <code>black_hand_rgb_3d.py</code> | 黑手套 RGB 二维关键点和预览空间效果 |
816
+ | <code>depth_hand_3d.py</code> | RGB-D 手部模块使用的真实 3D 计算辅助代码,不是独立节点 |
817
+ | <code>black_glove_hand.py</code> | 黑手套关键点处理 |
818
+ | <code>ai_annotation.py</code> | 声明并触发 AI 自动标注 |
819
+ | <code>annotation.py</code> | 人工标注工作流节点 |
820
+ | <code>human_review.py</code> | 人工审核门 |
821
+ | <code>ai_quality_review.py</code> | 视频和标注自动质量检查 |
822
+ | <code>lerobot_export.py</code> | LeRobot 导出节点 |
823
+ | <code>hdf5_export.py</code> | HDF5 导出节点 |
824
+
825
+ 增加工作流模块时,应同时检查:
826
+
827
+ 1. 模块输入和输出类型;
828
+ 2. Worker 是否能够执行;
829
+ 3. 工作流编辑器是否正确显示;
830
+ 4. 处理结果是否能在审核页面读取;
831
+ 5. 导出模块是否包含新增结果。
832
+
833
+ ### 13.7 数据、缓存和导出
834
+
835
+ | 文件 | 作用 |
836
+ | --- | --- |
837
+ | <code>app/localstore.py</code> | 扫描和缓存项目、Episode、工作流及运行状态 |
838
+ | <code>app/storage.py</code> | 文件上传、校验、保存和远程同步 |
839
+ | <code>app/remote_storage.py</code> | 远程存储访问 |
840
+ | <code>app/media_groups.py</code> | 组织 RGB、深度和传感器媒体组 |
841
+ | <code>app/media_cache.py</code> | 服务端媒体缓存 |
842
+ | <code>app/browser_preview.py</code> | 生成浏览器兼容的视频预览 |
843
+ | <code>app/artifact_resolver.py</code> | 查找工作流处理结果 |
844
+ | <code>app/export_engine.py</code> | 统一导出流程 |
845
+ | <code>app/lerobot_export.py</code> | 生成 LeRobot 3.0 数据集 |
846
+ | <code>app/lerobot_v21.py</code> | 生成和兼容 LeRobot 2.1 数据 |
847
+ | <code>app/hdf5_export.py</code> | 生成 HDF5 数据集 |
848
+
849
+ 源数据、后处理结果和导出副本需要分清:
850
+
851
+ data/sessions/ # 项目源数据和合并后的后处理字段
852
+ data/tmp/ # 临时处理文件
853
+ 浏览器 IndexedDB # 前端预览缓存,可以重新生成
854
+ 导出压缩包 # 根据工作流设置生成的交付文件
855
+
856
+ 不要把浏览器伪彩色深度图当作源数据保存。源深度仍然是原始深度码。
857
+
858
+ ### 13.8 修改功能时去哪里
859
+
860
+ | 需要修改的功能 | 前端位置 | 后端或处理位置 |
861
+ | --- | --- | --- |
862
+ | 首页统计 | <code>dashboard.js</code> | <code>routes/dashboard.py</code> |
863
+ | 项目页面 | <code>tasks.js</code> | <code>api/projects.py</code> |
864
+ | 上传和解压 | 项目页面 | <code>routes/session.py</code> |
865
+ | 工作流编辑器 | <code>workflow-studio/src/</code> | <code>api/workflows.py</code> |
866
+ | 工作流自动运行 | 项目状态显示 | <code>workflow_dispatch.py</code> |
867
+ | RGB 视频播放 | <code>player.js</code> | <code>routes/video.py</code> |
868
+ | 播放帧同步 | <code>player.js</code> | <code>routes/video.py</code> |
869
+ | 2D 手部关键点 | <code>hand-overlay.js</code> | 手部处理模块 |
870
+ | 3D 手部空间 | <code>player.js</code> | <code>processing/modules/*3d*.py</code> |
871
+ | 深度伪彩色 | <code>depth-renderer.js</code> | <code>routes/video.py</code> |
872
+ | 手套传感器 | <code>heatmap.js</code> | <code>glove_sensor.py</code> |
873
+ | 标注时间条 | <code>annotations.js</code> | <code>routes/annotations.py</code> |
874
+ | AI 标注 | <code>annotations.js</code> | <code>ai_annotation.py</code> |
875
+ | 视频审核 | <code>app.js</code> | <code>routes/ingestion.py</code> |
876
+ | 单个和批量导出 | <code>app.js</code> | <code>routes/export.py</code> |
877
+ | LeRobot 格式 | 导出按钮 | <code>lerobot_export.py</code>、<code>lerobot_v21.py</code> |
878
+ | HDF5 格式 | 导出按钮 | <code>hdf5_export.py</code> |
879
+ | 垃圾桶 | <code>trash.html</code> | <code>routes/ingestion.py</code> |
880
+ | 用户管理 | <code>users.js</code> | <code>api/users.py</code> |
881
+
882
+ ### 13.9 排查问题的顺序
883
+
884
+ 遇到问题时,建议按照下面顺序检查:
885
+
886
+ 1. 先确认问题出现在哪个页面;
887
+ 2. 找到该页面对应的 JavaScript;
888
+ 3. 在浏览器开发者工具中检查接口是否失败;
889
+ 4. 根据接口地址找到对应的 Python 路由;
890
+ 5. 如果是工作流任务,再检查 Worker 和处理模块;
891
+ 6. 如果是显示不同步,再检查当前 Episode 的帧数、帧率和缓存;
892
+ 7. 修改后运行测试,再打开真实数据验证。
893
+
894
+ ## 结尾
895
+
896
+ 先上传,再处理;处理完成后检查;检查正确后再导出。
processor/docs/en/README.md ADDED
@@ -0,0 +1,862 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # processor Project Guide
2
+
3
+ processor is a system for receiving, processing, reviewing, and exporting data.
4
+
5
+ This guide keeps the project features, pages, screenshots, and operating instructions in one document. Read it from top to bottom without switching between multiple files.
6
+
7
+ ## Contents
8
+
9
+ 1. [Overall process](#1-overall-process)
10
+ 2. [Installation and startup](#2-installation-and-startup)
11
+ 3. [Home](#3-home)
12
+ 4. [Projects and received data](#4-projects-and-received-data)
13
+ 5. [Workflow](#5-workflow)
14
+ 6. [Data annotation](#6-data-annotation)
15
+ 7. [Video viewing and review](#7-video-viewing-and-review)
16
+ 8. [Approved data and export](#8-approved-data-and-export)
17
+ 9. [Failed data](#9-failed-data)
18
+ 10. [Trash](#10-trash)
19
+ 11. [Frequently asked questions](#11-frequently-asked-questions)
20
+ 12. [Data and program information](#12-data-and-program-information)
21
+ 13. [Code structure and responsibilities](#13-code-structure-and-responsibilities)
22
+
23
+ ## 1. Overall process
24
+
25
+ The complete process is:
26
+
27
+ Install and start
28
+ ↓
29
+ Open Home
30
+ ↓
31
+ Create or select a project
32
+ ↓
33
+ Receive data
34
+ ↓
35
+ Select and run a workflow
36
+ ↓
37
+ View videos and processing results
38
+ ↓
39
+ Annotate, inspect, and review
40
+ ↓
41
+ Export data
42
+
43
+ The main pages currently available in the system are:
44
+
45
+ - Home: view the overall data status;
46
+ - Projects: view projects and Episodes;
47
+ - Workflow: configure and run workflows;
48
+ - Annotation: manage annotations;
49
+ - Video Review: view videos and review data;
50
+ - Trash: view deleted data.
51
+
52
+ The Users feature is still under development and is not included in the current operating process.
53
+
54
+ ## 2. Installation and startup
55
+
56
+ ### 2.1 Download and open the project
57
+
58
+ After downloading the project, open:
59
+
60
+ processor/
61
+
62
+ ### 2.2 Start the program
63
+
64
+ Linux:
65
+
66
+ cd processor
67
+ python deploy.py
68
+
69
+ To check the computer environment without starting the services:
70
+
71
+ python deploy.py --check-only
72
+
73
+ When using an external AI API:
74
+
75
+ python deploy.py --skip-vllm
76
+
77
+ Windows users can run the Windows deployment script included in the project.
78
+
79
+ ### 2.3 Open the web page
80
+
81
+ Wait for the services to start, then open the address provided by the administrator in a browser.
82
+
83
+ When the sign-in page appears, the web application has started. Open:
84
+
85
+ /health
86
+
87
+ to check whether the service is healthy.
88
+
89
+ > Screenshot to be added: successful startup and sign-in page.
90
+
91
+ ### 2.4 If something goes wrong
92
+
93
+ - If the page does not open, check whether the backend is running.
94
+ - If data is not processed, check whether the Worker is running.
95
+ - After a workflow is configured, matching uploaded data should run through it automatically. If processing does not start, check the workflow or run it manually.
96
+
97
+ ## 3. Home
98
+
99
+ ### 3.1 What is Home for?
100
+
101
+ Home shows the overall status of all data.
102
+
103
+ ### 3.2 Home page
104
+
105
+ ![Home page](../images/02-home-annotated.png)
106
+
107
+ The numbered areas are:
108
+
109
+ 1. Left navigation menu;
110
+ 2. Summary statistics;
111
+ 3. Project list;
112
+ 4. Recent data;
113
+ 5. Refresh button.
114
+
115
+ ### 3.3 Data statuses
116
+
117
+ | Status | Meaning |
118
+ | --- | --- |
119
+ | Reviewing | Waiting for or undergoing inspection |
120
+ | Approved | Review passed |
121
+ | Failed | Processing failed |
122
+ | Trash | Deleted and recoverable |
123
+
124
+ ### 3.4 How to use it
125
+
126
+ 1. Sign in and open Home.
127
+ 2. Check the summary statistics at the top.
128
+ 3. Click a project name to view the project.
129
+ 4. If the data is not current, click Refresh in the upper-right corner.
130
+
131
+ ### 3.5 What indicates normal operation?
132
+
133
+ When data changes from Reviewing to Approved, its inspection is complete and it has passed review.
134
+
135
+ If Processing remains visible, wait and refresh the page. If it still does not change, check the Worker.
136
+
137
+ ## 4. Projects and received data
138
+
139
+ ### 4.1 What is a project?
140
+
141
+ A project stores a related group of data. One project can contain multiple Episodes.
142
+
143
+ processor receives data sent by the collector and stores it in the corresponding project. Data acquisition itself does not take place inside processor.
144
+
145
+ ### 4.2 View existing projects
146
+
147
+ Open <code>Projects</code> in the left navigation to view existing projects and received data.
148
+
149
+ [![Project list](../images/projects-list-annotated.png)](../images/projects-list-annotated.png)
150
+
151
+ The numbered controls are:
152
+
153
+ 1. <code>Search projects</code>: enter a project name to find it quickly;
154
+ 2. <code>New Project</code>: create a new project that will receive data;
155
+ 3. <code>Project information</code>: view the project name, status, workflow, observed inputs, Episode count, and receiving statistics;
156
+ 4. <code>Edit Project</code>: change the project name, workflow, or status;
157
+ 5. <code>Delete</code>: delete the current project. Deleted data can be viewed in Trash;
158
+ 6. <code>Expand received batches</code>: click the project row or the arrow on the right to view received batches and Episodes.
159
+
160
+ <code>active</code> means the project is in use and can continue receiving data. <code>paused</code> means the project is paused.
161
+
162
+ ### 4.3 Create a project
163
+
164
+ Click <code>New Project</code> in the upper-right corner and enter the project information.
165
+
166
+ [![Create a project](../images/project-new-annotated.png)](../images/project-new-annotated.png)
167
+
168
+ The fields are:
169
+
170
+ - <code>Project Name</code>: the project name;
171
+ - <code>Workflow</code>: bind one current workflow to the project. You may create a new empty workflow or select an existing workflow;
172
+ - <code>Status</code>: <code>active</code> enables the project and <code>paused</code> pauses it;
173
+ - <code>Target Episodes</code>: planned number of Episodes. <code>0</code> means no limit;
174
+ - <code>Description</code>: optional project description;
175
+ - <code>Save</code>: save the project.
176
+
177
+ The project name cannot be empty. After a new project is saved, the system asks whether you want to open the Workflow page and continue configuration.
178
+
179
+ ### 4.4 Select a workflow
180
+
181
+ Select the required workflow from the <code>Workflow</code> field on the project form.
182
+
183
+ [![Select a project workflow](../images/project-workflow-select-annotated.png)](../images/project-workflow-select-annotated.png)
184
+
185
+ The workflow list provides:
186
+
187
+ 1. Click the <code>Workflow</code> field to open the workflow list;
188
+ 2. Click <code>+ New Workflow</code> to create an empty workflow;
189
+ 3. A workflow with the <code>Template</code> tag is a template workflow;
190
+ 4. Click an existing workflow name to bind it to the current project;
191
+ 5. Click the button on the right to open and edit that workflow.
192
+
193
+ One project uses one current workflow. Selecting another workflow saves it as the project's new current workflow binding.
194
+
195
+ Choose according to the input data:
196
+
197
+ - Choose an RGB workflow for RGB data.
198
+ - Choose an RGB-D workflow for RGB plus Depth data.
199
+ - Choose a workflow containing Glove Sensor when glove sensor data is present.
200
+ - If you are unsure, confirm the project's input types before making a selection.
201
+
202
+ ### 4.5 Data receiving process
203
+
204
+ Collector generates data
205
+ ↓
206
+ Collector sends data
207
+ ↓
208
+ processor receives data
209
+ ↓
210
+ Extract and inspect
211
+ ↓
212
+ Match the project and workflow
213
+ ↓
214
+ New Episodes appear in the project
215
+
216
+ ### 4.6 How to receive data
217
+
218
+ 1. Create a project and select the correct workflow.
219
+ 2. Keep the project status set to <code>active</code>.
220
+ 3. Send data from the collector to processor.
221
+ 4. Wait for processor to receive, extract, and inspect the data.
222
+ 5. Expand the project and confirm that the new batch and Episodes appear.
223
+
224
+ When a project already has a workflow, the system processes newly received data. Without a bound workflow, data is only received and stored. If a workflow is bound and saved later, the system processes the existing batches.
225
+
226
+ ### 4.7 Important notes
227
+
228
+ - Do not close the collector's sending program while data is being transferred.
229
+ - Do not send the same data again before extraction and inspection finish.
230
+ - Each Episode should contain its corresponding video or sensor data.
231
+ - If receiving fails, read the failure message before sending or processing the data again.
232
+ - Do not force a workflow to run when the project inputs do not match it.
233
+
234
+ ## 5. Workflow
235
+
236
+ ### 5.1 What is a workflow?
237
+
238
+ A workflow determines how received data is processed.
239
+
240
+ Input data → Process data → Inspect results → Export data
241
+
242
+ ### 5.2 Workflow page
243
+
244
+ Click <code>Workflow</code> in the left navigation.
245
+
246
+ [![Workflow page](../images/workflow-empty-annotated.png)](../images/workflow-empty-annotated.png)
247
+
248
+ The numbered controls are:
249
+
250
+ 1. <code>Workflow selector</code>: switch between existing workflows or create a new workflow;
251
+ 2. <code>Workflow name</code>: view or change the current workflow name;
252
+ 3. <code>Canvas</code>: place nodes and connect them into a processing flow;
253
+ 4. <code>Node list</code>: input, processing, review, and export nodes;
254
+ 5. <code>Template</code>: start from a workflow template;
255
+ 6. <code>Save</code>: save the current workflow.
256
+
257
+ ### 5.3 Start from a template
258
+
259
+ Click <code>Template</code> in the upper-right corner and select a template that matches the received data.
260
+
261
+ [![Select a workflow template](../images/workflow-template-annotated.png)](../images/workflow-template-annotated.png)
262
+
263
+ 1. <code>RGB-D_Workflow</code>: a processing chain for RGB and depth data;
264
+ 2. <code>Stereo-RGB_Workflow</code>: a processing chain for left and right RGB data;
265
+ 3. <code>Cancel</code>: close the dialog without applying a template.
266
+
267
+ A template can be applied to a new or existing workflow. Applying it replaces the processing chain on the current canvas but does not change the current workflow name.
268
+
269
+ A template contains processing steps only. Input device nodes are generated from the data actually received by the project. If the canvas contains unsaved changes, save them before applying another template.
270
+
271
+ ### 5.4 Check a complete workflow
272
+
273
+ The following image shows a connected workflow.
274
+
275
+ [![Complete workflow](../images/workflow-editor-annotated.png)](../images/workflow-editor-annotated.png)
276
+
277
+ 1. <code>Stereo RGB Camera</code>: left and right RGB videos received by the project;
278
+ 2. <code>Glove Sensor</code>: glove sensor data received by the project;
279
+ 3. <code>AI Annotation</code>: generate task text and annotation segments from RGB video;
280
+ 4. <code>Human Review</code>: send video, sensor data, and annotations for manual review;
281
+ 5. <code>LeRobot Export</code>: prepare the data for export using the workflow settings;
282
+ 6. <code>Save</code>: save the workflow after checking its connections.
283
+
284
+ In this example, the video, glove data, and AI annotations all enter manual review:
285
+
286
+ Stereo RGB Camera ─┬→ AI Annotation ─┐
287
+ └─────────────────┤
288
+ Glove Sensor ────────────────────────┤
289
+ ↓
290
+ Human Review
291
+ ↓
292
+ LeRobot Export
293
+
294
+ ### 5.5 AI annotation settings
295
+
296
+ Open the settings dialog of the <code>AI Annotation</code> node to select the annotation language and API.
297
+
298
+ [![AI annotation settings](../images/workflow-ai-settings-annotated.png)](../images/workflow-ai-settings-annotated.png)
299
+
300
+ 1. <code>Label Language</code>: choose Chinese or English annotations;
301
+ 2. <code>Saved API Profile</code>: select a saved API profile;
302
+ 3. <code>API Provider</code>: select the API provider;
303
+ 4. <code>API Model</code>: enter or select the model;
304
+ 5. <code>API Key</code>: enter the API key;
305
+ 6. <code>API Base URL</code>: enter the API address. Leave it empty to use the provider's official default;
306
+ 7. <code>Test Connection</code>: test whether the API is available before saving;
307
+ 8. <code>Save</code>: save the AI annotation settings.
308
+
309
+ > API keys should appear masked. Do not share screenshots that reveal a complete API key.
310
+
311
+ ### 5.6 Workflow module reference
312
+
313
+ The node list on the right is divided into <code>INPUT</code>, <code>PROCESS</code>, <code>REVIEW</code>, and <code>EXPORT</code>. Drag a node onto the canvas and connect its inputs and outputs.
314
+
315
+ #### INPUT: source data
316
+
317
+ | Module | Receives | Provides | When to use it |
318
+ | --- | --- | --- | --- |
319
+ | Glove Sensor | Glove pressure and joint data | Glove Sensor Data | When the project contains glove sensor data |
320
+ | RGB Camera | One color video | RGB Video | A monocular standard camera, monocular fisheye camera, or the color stream of an RGB-D camera |
321
+ | RGB-D Camera | Color video and a real depth stream | RGB Video, Depth | One RGB-D camera with depth data |
322
+ | Stereo RGB Camera | Left and right color videos | Left RGB Video, Right RGB Video | Stereo RGB video without a direct real-depth output |
323
+ | Stereo RGB-D Camera | Left and right color videos plus depth | Left RGB Video, Right RGB Video, Depth | Stereo video that also contains real depth |
324
+
325
+ #### PROCESS: processed data
326
+
327
+ | Module | Requires | Produces | Purpose |
328
+ | --- | --- | --- | --- |
329
+ | Human Annotation | RGB Video | Annotation | Manually create and edit task segments |
330
+ | AI Annotation | RGB Video | Annotation | Automatically generate Chinese or English annotations using a local model or API |
331
+ | RGB_TO_2D_BareHand | RGB Video | Hand 2D | Detect 2D keypoints in bare-hand video |
332
+ | RGB_TO_2D_BlackGlove | RGB Video | Hand 2D | Detect 2D keypoints in black-glove video |
333
+ | RGB-D_3D_BareHand | RGB Video + Depth | Hand 3D | Calculate real 3D bare-hand keypoints using matched depth data |
334
+ | RGB-D_3D_BlackGlove | RGB Video + Depth | Hand 3D | Calculate real 3D black-glove keypoints using matched depth data |
335
+
336
+ The <code>RGB_TO_2D</code> modules produce two-dimensional keypoints. Their spatial preview is for viewing only and is not exported as real metric 3D data.
337
+
338
+ #### REVIEW: inspect data
339
+
340
+ | Module | Purpose | Result |
341
+ | --- | --- | --- |
342
+ | Human Review | Let a user play the video and inspect keypoints, depth, sensor data, and annotations | Output Reviewed Data after approval |
343
+ | AI Quality Review | Automatically check decoding, frame continuity, black or frozen frames, and AI annotation coverage | Output Reviewed Data after passing; send abnormal data for manual handling |
344
+
345
+ #### EXPORT: export data
346
+
347
+ | Module | Purpose | Main settings |
348
+ | --- | --- | --- |
349
+ | LeRobot Export | Export a LeRobot dataset | Select v2.1 or v3.0; includes the corresponding meta, data, and videos |
350
+ | HDF5 Export | Export one HDF5 data file | Save with gzip compression |
351
+
352
+ Connect an export module after a review module so that only inspected and approved data enters the export flow.
353
+
354
+ ### 5.7 Common workflow examples
355
+
356
+ Standard RGB:
357
+
358
+ RGB → Hand 2D → Human Review → Export
359
+
360
+ RGB and depth:
361
+
362
+ RGB + Depth → Hand 3D → Human Review → Export
363
+
364
+ RGB and glove sensor:
365
+
366
+ RGB + Glove Sensor → Human Review → Export
367
+
368
+ ### 5.8 Important rules
369
+
370
+ - Both RGB and Depth are required to calculate real 3D.
371
+ - RGB alone can produce 2D keypoints.
372
+ - A spatial preview produced from RGB alone is not real metric 3D.
373
+ - A step is skipped when its required input does not match the Episode.
374
+
375
+ <code>Input mismatch</code> means the workflow requires different data from the current project. Select a matching workflow.
376
+
377
+ ## 6. Data annotation
378
+
379
+ ### 6.1 Select data to annotate
380
+
381
+ Click <code>Annotation</code> in the left navigation. Expand a project on the right, then select an Episode.
382
+
383
+ [![Annotation data list](../images/annotation-list-annotated.png)](../images/annotation-list-annotated.png)
384
+
385
+ 1. <code>Annotation</code>: open the Annotation page;
386
+ 2. Click a project name to expand it;
387
+ 3. Click an Episode number to load its video and existing annotations.
388
+
389
+ If the center shows <code>Select an episode to review</code>, no Episode has been selected. This is not a system failure.
390
+
391
+ ### 6.2 View and edit annotations
392
+
393
+ [![Annotation editor](../images/annotation-editor-annotated.png)](../images/annotation-editor-annotated.png)
394
+
395
+ 1. <code>Preview Options</code>: show or hide keypoints, trails, depth, and 3D previews;
396
+ 2. <code>RGB and 2D keypoints</code>: view the original image and 2D hand keypoints;
397
+ 3. <code>3D hand space</code>: view hand keypoints in space;
398
+ 4. <code>Depth pseudo-color preview</code>: view depth data in the browser;
399
+ 5. <code>AI Annotate</code>: run AI annotation using the workflow settings;
400
+ 6. <code>Annotation segment</code>: click a segment on the right to view or edit it;
401
+ 7. <code>Set Start / Set End</code>: use the current frame as the segment's start or end frame;
402
+ 8. <code>Save Changes</code>: save changes to the segment name and frame range.
403
+
404
+ ### 6.3 Manually edit an annotation
405
+
406
+ 1. Click the annotation segment that needs editing.
407
+ 2. Check its start frame, end frame, and task name.
408
+ 3. Make the changes and click <code>Save Changes</code>.
409
+ 4. Check the annotation block on the timeline at the bottom.
410
+
411
+ ### 6.4 AI annotation
412
+
413
+ 1. Confirm that <code>AI Annotation</code> is configured in the workflow.
414
+ 2. Select Chinese or English in the workflow settings.
415
+ 3. Click <code>AI Annotate</code>.
416
+ 4. Wait for the annotation segments to be generated.
417
+ 5. Manually inspect the result and correct any errors.
418
+ 6. If the workflow contains this feature, AI annotation runs automatically during workflow processing.
419
+ 7. Network fluctuations may occasionally leave a short segment without annotation information.
420
+
421
+ ## 7. Video viewing and review
422
+
423
+ ### 7.1 Select data awaiting review
424
+
425
+ Click <code>Reviewing</code> under <code>Video Review</code>, then select an Episode from the right panel.
426
+
427
+ [![Reviewing list](../images/reviewing-list-annotated.png)](../images/reviewing-list-annotated.png)
428
+
429
+ 1. <code>Reviewing</code>: view data waiting for manual review;
430
+ 2. <code>Expand project</code>: view the Episodes in a project;
431
+ 3. <code>Select Episode</code>: open the data and view its FPS, frame count, cameras, and processing status;
432
+ 4. <code>Approve</code>: approve the current Episode after checking it;
433
+ 5. <code>Reprocess</code>: run the workflow bound to the project again when the result is incorrect.
434
+
435
+ ### 7.2 Inspect video and processing results
436
+
437
+ [![Video review](../images/reviewing-player-annotated.png)](../images/reviewing-player-annotated.png)
438
+
439
+ 1. <code>Preview Options</code>: show or hide different preview layers;
440
+ 2. <code>RGB image</code>: view the color video. When keypoint display is enabled, 2D hand keypoints are overlaid;
441
+ 3. <code>3D hand space</code>: inspect hand keypoints in space;
442
+ 4. <code>Depth pseudo-color preview</code>: check whether the depth data looks normal;
443
+ 5. <code>Read-only annotation segments</code>: inspect task names and frame ranges;
444
+ 6. <code>Playback and frame controls</code>: play the video or inspect details with the previous-frame and next-frame buttons;
445
+ 7. <code>Approve</code>: approve the current Episode.
446
+
447
+ Wait for the related data to finish loading before playback. RGB, 2D, 3D, depth, and sensor views should display the same frame number.
448
+
449
+ ### 7.3 About the depth view
450
+
451
+ Blue, green, yellow, and other colors in the depth view are only a browser preview.
452
+
453
+ The depth video stores raw 12-bit depth codes. The pseudo-color image is not written back to the original data.
454
+
455
+ ### 7.4 Common display problems
456
+
457
+ - Black screen: wait for the current Episode to load, then refresh the page.
458
+ - No 3D hand: check whether the workflow includes 3D processing and whether the Episode contains Depth.
459
+ - Keypoints do not move: reprocess that Episode.
460
+ - Playback stutters: wait until the related data has been cached before playing.
461
+
462
+ ## 8. Approved data and export
463
+
464
+ ### 8.1 Approved data
465
+
466
+ Click <code>Approved</code> under <code>Video Review</code> to view data that has passed review.
467
+
468
+ [![Approved data and export](../images/approved-list-annotated.png)](../images/approved-list-annotated.png)
469
+
470
+ 1. <code>Approved</code>: open the Approved list;
471
+ 2. <code>Multi-select mode</code>: click <code>Select</code> at the top of the page. The button changes to <code>Cancel</code> while this mode is active;
472
+ 3. <code>Select All</code>: select every Episode in the current list;
473
+ 4. <code>Episode checkbox</code>: select one Episode or multiple Episodes;
474
+ 5. <code>Export</code>: export this single Episode;
475
+ 6. <code>Unreview</code>: remove approval and return the Episode to the Reviewing list.
476
+
477
+ ### 8.2 Export process
478
+
479
+ Export one Episode:
480
+
481
+ 1. Click <code>Export</code> on that Episode's card.
482
+ 2. Wait for the export to finish and download the archive.
483
+
484
+ Batch export:
485
+
486
+ 1. Click <code>Select</code> at the top of the page.
487
+ 2. Select one or more Episodes, or click <code>Select All</code>.
488
+ 3. A <code>Batch Download</code> action bar appears after Episodes are selected.
489
+ 4. Click <code>Batch Download</code> and wait for the export to finish.
490
+
491
+ The export format comes from the current workflow's export node, such as LeRobot 2.1, LeRobot 3.0, or HDF5. No separate export page is required.
492
+
493
+ ### 8.3 Exported content
494
+
495
+ Project name/
496
+ ├── meta/ # Descriptions and statistics
497
+ ├── data/ # Per-frame data, keypoints, and sensor data
498
+ └── videos/ # Videos
499
+
500
+ Processed keypoints, annotations, and glove sensor data are exported together with the corresponding source data.
501
+
502
+ ## 9. Failed data
503
+
504
+ ### 9.1 Handling process
505
+
506
+ See Failed
507
+ ↓
508
+ Open the failed data
509
+ ↓
510
+ Read the error message
511
+ ↓
512
+ Click Retry or Reprocess
513
+ ↓
514
+ Wait for processing
515
+
516
+ > Screenshot to be added: Failed page.
517
+
518
+ ### 9.2 Common errors
519
+
520
+ | Error | What to do |
521
+ | --- | --- |
522
+ | No RGB | Check whether a color video was uploaded |
523
+ | No Depth | Real 3D cannot be calculated |
524
+ | Workflow mismatch | Select a matching workflow |
525
+ | AI annotation failed | Check the API settings and retry |
526
+ | Video cannot be opened | Send it again from the collector or contact the administrator |
527
+ | Worker is not running | Ask the administrator to restart the service |
528
+
529
+ If processing still fails after two retries, provide the data name, an error screenshot, the time of failure, and the steps that caused it.
530
+
531
+ ## 10. Trash
532
+
533
+ ### 10.1 What is Trash for?
534
+
535
+ Deleted data first enters Trash.
536
+
537
+ ### 10.2 How to use it
538
+
539
+ [![Trash](../images/trash-annotated.png)](../images/trash-annotated.png)
540
+
541
+ 1. Click <code>Trash</code> in the left navigation;
542
+ 2. Check the remaining retention time;
543
+ 3. Click <code>Restore</code> if the data should be kept;
544
+ 4. Click <code>Delete</code> only when the data can be permanently removed;
545
+ 5. <code>Purge All</code> permanently deletes everything in Trash.
546
+
547
+ ### 10.3 Important notes
548
+
549
+ - Data in Trash can be restored.
550
+ - Permanently deleted data usually cannot be recovered.
551
+ - Confirm again before emptying Trash.
552
+ - If data cannot be found, first check its project name and deletion time.
553
+
554
+ ## 11. Frequently asked questions
555
+
556
+ ### The page does not open
557
+
558
+ Check whether the service is running.
559
+
560
+ ### Data remains in Processing
561
+
562
+ Refresh the page. If the status does not change for a long time, the Worker may not be running.
563
+
564
+ ### The depth view is black
565
+
566
+ Wait for loading to finish, refresh the page, and confirm that the current Episode really contains a depth video.
567
+
568
+ ### There is 2D but no 3D
569
+
570
+ Real 3D requires both RGB and Depth. RGB alone cannot calculate real spatial positions.
571
+
572
+ ### Playback stutters
573
+
574
+ Wait for loading to finish before playing. Use the previous-frame and next-frame buttons when checking details.
575
+
576
+ ### AI annotation has no result
577
+
578
+ Check the API settings, model, network, and language, then run AI annotation again.
579
+
580
+ ### Export fails
581
+
582
+ Confirm that the data has passed review, then export it again.
583
+
584
+ ## 12. Data and program information
585
+
586
+ ### 12.1 Project directory
587
+
588
+ processor/
589
+ ├── app/ # Backend, workflow, video, and export
590
+ ├── worker/ # Background processing tasks
591
+ ├── web/ # Pages and frontend code
592
+ ├── scripts/ # Startup, inspection, and maintenance scripts
593
+ ├── tests/ # Automated tests
594
+ ├── deploy.py # One-command deployment entry point
595
+ └── .env # Local configuration; do not commit to Git
596
+
597
+ ### 12.2 Project data directory
598
+
599
+ data/sessions/<project>/
600
+ ├── data/
601
+ ├── meta/
602
+ └── videos/
603
+
604
+ Raw depth videos store depth codes. The browser's pseudo-color preview is not written back to the original data.
605
+
606
+ ### 12.3 Startup checks
607
+
608
+ python deploy.py --check-only
609
+ python -m compileall app worker
610
+ PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q
611
+
612
+ Do not commit <code>.env</code>, API keys, tokens, real data, or server credentials to Git.
613
+
614
+ ## 13. Code structure and responsibilities
615
+
616
+ This chapter explains the frontend, backend, Worker, and data-processing code behind the pages. Normal operation does not require changing these files. When maintaining a feature, use this chapter to find the corresponding code quickly.
617
+
618
+ ### 13.1 System code flow
619
+
620
+ Browser page
621
+ ↓
622
+ HTML templates and JavaScript
623
+ ↓
624
+ FastAPI backend APIs
625
+ ↓
626
+ Projects, Episodes, annotations, and workflows
627
+ ↓
628
+ Worker claims a processing job
629
+ ↓
630
+ Workflow modules run post-processing
631
+ ↓
632
+ Save keypoints, 3D, sensor, and annotation results
633
+ ↓
634
+ Video review and data export
635
+
636
+ Business data is primarily stored in project files. User accounts can use a database. The application retains a compatibility fallback when the database is temporarily unavailable.
637
+
638
+ ### 13.2 Code directory tree
639
+
640
+ processor/
641
+ ├── app/ # Python backend
642
+ │ ├── main.py # FastAPI application entry point
643
+ │ ├── config.py # Environment variables and system settings
644
+ │ ├── paths.py # Shared directory paths
645
+ │ ├── localstore.py # Project, workflow, and Episode file state
646
+ │ ├── database.py # User account database connection
647
+ │ ├── models.py # Database models
648
+ │ ├── workflow_dispatch.py # Workflow matching, dispatch, and backfill
649
+ │ ├── workflow_bindings.py # Bind workflows to actual device inputs
650
+ │ ├── ai_annotation.py # AI annotation service
651
+ │ ├── export_engine.py # Shared export-job logic
652
+ │ ├── lerobot_export.py # LeRobot 3.0 dataset generation
653
+ │ ├── lerobot_v21.py # LeRobot 2.1 compatibility
654
+ │ ├── hdf5_export.py # HDF5 dataset generation
655
+ │ ├── routes/ # Page and business APIs
656
+ │ ├── api/ # Project, workflow, and Worker APIs
657
+ │ └── processing/ # Workflow processing framework
658
+ │ ├── registry.py # Automatic module registration
659
+ │ ├── catalog.py # Module catalog for the workflow page
660
+ │ ├── batch.py # Find videos and Parquet files in a batch
661
+ │ └── modules/ # Execution code for workflow nodes
662
+ ├── worker/ # Background processing program
663
+ │ ├── __main__.py # Worker entry point
664
+ │ ├── runner.py # Claim and execute workflow jobs
665
+ │ └── client.py # Communicate with the backend Worker API
666
+ ├── web/ # Frontend pages
667
+ │ ├── templates/ # HTML page templates
668
+ │ ├── static/js/ # Page interaction, playback, and rendering
669
+ │ └── workflow-studio/ # React workflow editor source
670
+ ├── scripts/ # Deployment, migration, and maintenance
671
+ ├── tests/ # Automated tests
672
+ ├── models/ # Local model files
673
+ ├── docs/ # Chinese and English documentation
674
+ ├── deploy.py # One-command deployment entry point
675
+ └── requirements*.txt # Python dependencies
676
+
677
+ <code>.venv*</code>, <code>.pytest_cache</code>, and <code>.backups</code> are local environments, test caches, or backups. They are not part of the main business code.
678
+
679
+ ### 13.3 Frontend code
680
+
681
+ The frontend has two parts:
682
+
683
+ 1. Standard pages use HTML templates and plain JavaScript.
684
+ 2. The workflow editor uses React and TypeScript.
685
+
686
+ #### Page templates
687
+
688
+ | File | Responsibility |
689
+ | --- | --- |
690
+ | <code>web/templates/base.html</code> | Common page frame, left navigation, and shared resources |
691
+ | <code>web/templates/overview.html</code> | Home page |
692
+ | <code>web/templates/tasks.html</code> | Project management page |
693
+ | <code>web/templates/index.html</code> | Annotation and video-review pages |
694
+ | <code>web/templates/trash.html</code> | Trash page |
695
+ | <code>web/templates/login.html</code> | Login page |
696
+ | <code>web/templates/users.html</code> | User management page, still under development |
697
+ | <code>web/templates/workflow_studio.html</code> | Workflow editor entry page |
698
+
699
+ #### Page JavaScript
700
+
701
+ | File | Responsibility |
702
+ | --- | --- |
703
+ | <code>web/static/js/dashboard.js</code> | Home statistics, recent data, and refresh |
704
+ | <code>web/static/js/tasks.js</code> | Create, edit, delete, and expand projects and batches |
705
+ | <code>web/static/js/app.js</code> | Episode list, review, deletion, and export actions |
706
+ | <code>web/static/js/player.js</code> | Video loading, central playback clock, and frame synchronization |
707
+ | <code>web/static/js/annotations.js</code> | Annotation segments, timeline, and frame-level annotation |
708
+ | <code>web/static/js/slice-preview.js</code> | Annotation segment preview in the upper-right corner |
709
+ | <code>web/static/js/hand-overlay.js</code> | Draw 2D hand keypoints over the RGB video |
710
+ | <code>web/static/js/depth-renderer.js</code> | Render raw 12-bit depth codes as browser pseudo-color |
711
+ | <code>web/static/js/heatmap.js</code> | Display glove sensor data in sync with video |
712
+ | <code>web/static/js/media-cache.js</code> | Cache keypoints and sensor data in IndexedDB |
713
+ | <code>web/static/js/i18n.js</code> | Chinese and English interface text |
714
+ | <code>web/static/js/login.js</code> | Login form |
715
+ | <code>web/static/js/users.js</code> | User management interactions, still under development |
716
+
717
+ The workflow editor source is in <code>web/workflow-studio/src/</code>. Its compiled files are in <code>web/static/workflow-studio/</code>. Change the source and rebuild the editor; do not edit the compiled output directly.
718
+
719
+ ### 13.4 Backend entry point and page APIs
720
+
721
+ <code>app/main.py</code> is the backend entry point. It is responsible for:
722
+
723
+ - Starting FastAPI;
724
+ - Initializing file directories and the user database;
725
+ - Warming project and Episode metadata caches;
726
+ - Starting the upload processing queue;
727
+ - Registering page, project, video, annotation, workflow, and export APIs;
728
+ - Configuring static-file caching and large-media transfer behavior.
729
+
730
+ | File | Responsibility |
731
+ | --- | --- |
732
+ | <code>app/routes/pages.py</code> | Return the Home, Projects, Review, Workflow, and Trash pages |
733
+ | <code>app/routes/dashboard.py</code> | Home statistics, recent Episodes, and trends |
734
+ | <code>app/routes/session.py</code> | Receive archives, extract files, normalize directories, and import metadata |
735
+ | <code>app/routes/ingestion.py</code> | Episode list, details, approval, reprocessing, deletion, and restoration |
736
+ | <code>app/routes/video.py</code> | RGB video, depth code, depth preview, 2D, 3D, and sensor APIs |
737
+ | <code>app/routes/annotations.py</code> | Create, edit, delete, and read per-frame annotations |
738
+ | <code>app/routes/export.py</code> | Single export, batch export, export status, and downloads |
739
+ | <code>app/routes/auth.py</code> | Login, logout, and current-user APIs |
740
+ | <code>app/routes/devices.py</code> | Collector heartbeat and input capabilities |
741
+
742
+ ### 13.5 Project, workflow, and Worker APIs
743
+
744
+ | File | Responsibility |
745
+ | --- | --- |
746
+ | <code>app/api/projects.py</code> | Create, edit, and delete projects; manage inputs and workflow bindings |
747
+ | <code>app/api/workflows.py</code> | Create, save, run, inspect usage, and retry workflows |
748
+ | <code>app/api/worker.py</code> | Claim jobs, download inputs, send heartbeats, and report completion or failure |
749
+ | <code>app/api/exceptions.py</code> | Aggregate and clear processing exceptions |
750
+ | <code>app/api/users.py</code> | Create users and manage roles, statuses, and deletion; still under development |
751
+
752
+ The backend and Worker job flow is:
753
+
754
+ Backend creates a workflow run
755
+ ↓
756
+ Worker claims the job
757
+ ↓
758
+ Worker downloads the Episode input
759
+ ↓
760
+ Workflow nodes execute
761
+ ↓
762
+ Worker uploads the result and reports completion
763
+ ↓
764
+ Episode enters Reviewing or Approved
765
+
766
+ The Worker sends regular heartbeats. If it stops, the job is not immediately lost; the backend can schedule it again after the lease expires.
767
+
768
+ ### 13.6 Workflow processing modules
769
+
770
+ In <code>app/processing/modules/</code>, one Python file usually represents one workflow node. Modules are registered automatically, displayed in the workflow editor, and executed by the Worker.
771
+
772
+ | Module file | Responsibility |
773
+ | --- | --- |
774
+ | <code>mono_camera.py</code> | Single RGB input |
775
+ | <code>rgbd_camera.py</code> | RGB and depth input |
776
+ | <code>stereo_camera.py</code> | Left and right stereo RGB input |
777
+ | <code>stereo_rgbd_camera.py</code> | Left and right RGB plus depth input |
778
+ | <code>glove_sensor.py</code> | Glove sensor input |
779
+ | <code>mediapipe_hand.py</code> | MediaPipe hand keypoint and gesture recognition |
780
+ | <code>rgb_hand_3d.py</code> | Bare-hand RGB 2D keypoints and spatial preview |
781
+ | <code>black_hand_rgb_3d.py</code> | Black-glove RGB 2D keypoints and spatial preview |
782
+ | <code>depth_hand_3d.py</code> | Real 3D calculation helper used by RGB-D hand modules; not a standalone node |
783
+ | <code>black_glove_hand.py</code> | Black-glove keypoint processing |
784
+ | <code>ai_annotation.py</code> | Declare and trigger automatic AI annotation |
785
+ | <code>annotation.py</code> | Human annotation workflow node |
786
+ | <code>human_review.py</code> | Human-review gate |
787
+ | <code>ai_quality_review.py</code> | Automatic video and annotation quality checks |
788
+ | <code>lerobot_export.py</code> | LeRobot export node |
789
+ | <code>hdf5_export.py</code> | HDF5 export node |
790
+
791
+ When adding a workflow module, check:
792
+
793
+ 1. The module input and output types;
794
+ 2. Whether the Worker can execute it;
795
+ 3. Whether the workflow editor displays it correctly;
796
+ 4. Whether the Review page can read its output;
797
+ 5. Whether export modules include the new output.
798
+
799
+ ### 13.7 Data, cache, and export code
800
+
801
+ | File | Responsibility |
802
+ | --- | --- |
803
+ | <code>app/localstore.py</code> | Scan and cache projects, Episodes, workflows, and run states |
804
+ | <code>app/storage.py</code> | Upload, validate, save, and remotely synchronize files |
805
+ | <code>app/remote_storage.py</code> | Remote storage access |
806
+ | <code>app/media_groups.py</code> | Organize RGB, depth, and sensor media groups |
807
+ | <code>app/media_cache.py</code> | Server-side media cache |
808
+ | <code>app/browser_preview.py</code> | Generate browser-compatible video previews |
809
+ | <code>app/artifact_resolver.py</code> | Locate workflow processing results |
810
+ | <code>app/export_engine.py</code> | Shared export flow |
811
+ | <code>app/lerobot_export.py</code> | Generate LeRobot 3.0 datasets |
812
+ | <code>app/lerobot_v21.py</code> | Generate and support LeRobot 2.1 data |
813
+ | <code>app/hdf5_export.py</code> | Generate HDF5 datasets |
814
+
815
+ Keep source data, processing results, and export copies distinct:
816
+
817
+ data/sessions/ # Project source data and merged processing fields
818
+ data/tmp/ # Temporary processing files
819
+ Browser IndexedDB # Frontend preview cache; can be regenerated
820
+ Export archives # Delivery files generated from workflow settings
821
+
822
+ Do not store the browser's pseudo-color depth images as source data. The source depth remains raw depth codes.
823
+
824
+ ### 13.8 Where to change a feature
825
+
826
+ | Feature | Frontend | Backend or processing |
827
+ | --- | --- | --- |
828
+ | Home statistics | <code>dashboard.js</code> | <code>routes/dashboard.py</code> |
829
+ | Projects page | <code>tasks.js</code> | <code>api/projects.py</code> |
830
+ | Upload and extraction | Projects page | <code>routes/session.py</code> |
831
+ | Workflow editor | <code>workflow-studio/src/</code> | <code>api/workflows.py</code> |
832
+ | Automatic workflow run | Project status display | <code>workflow_dispatch.py</code> |
833
+ | RGB video playback | <code>player.js</code> | <code>routes/video.py</code> |
834
+ | Playback frame synchronization | <code>player.js</code> | <code>routes/video.py</code> |
835
+ | 2D hand keypoints | <code>hand-overlay.js</code> | Hand-processing modules |
836
+ | 3D hand space | <code>player.js</code> | <code>processing/modules/*3d*.py</code> |
837
+ | Depth pseudo-color | <code>depth-renderer.js</code> | <code>routes/video.py</code> |
838
+ | Glove sensor | <code>heatmap.js</code> | <code>glove_sensor.py</code> |
839
+ | Annotation timeline | <code>annotations.js</code> | <code>routes/annotations.py</code> |
840
+ | AI annotation | <code>annotations.js</code> | <code>ai_annotation.py</code> |
841
+ | Video review | <code>app.js</code> | <code>routes/ingestion.py</code> |
842
+ | Single and batch export | <code>app.js</code> | <code>routes/export.py</code> |
843
+ | LeRobot format | Export button | <code>lerobot_export.py</code>, <code>lerobot_v21.py</code> |
844
+ | HDF5 format | Export button | <code>hdf5_export.py</code> |
845
+ | Trash | <code>trash.html</code> | <code>routes/ingestion.py</code> |
846
+ | User management | <code>users.js</code> | <code>api/users.py</code> |
847
+
848
+ ### 13.9 Troubleshooting order
849
+
850
+ When a problem occurs, check it in this order:
851
+
852
+ 1. Confirm which page shows the problem.
853
+ 2. Find the JavaScript file used by that page.
854
+ 3. Check the browser developer tools for failed API requests.
855
+ 4. Use the API address to find the corresponding Python route.
856
+ 5. If it is a workflow job, inspect the Worker and processing module.
857
+ 6. If displays are out of sync, inspect the current Episode's frame count, FPS, and cache.
858
+ 7. Run the tests after making changes, then validate with real data.
859
+
860
+ ## Closing note
861
+
862
+ Receive the data first, process it next, inspect it after processing, and export it only after it passes review.
processor/docs/images/02-home-annotated.png ADDED

Git LFS Details

  • SHA256: 43c9e5d7e59d2502bc5490307ca4778fa1c5dedb6e12bf974905d6dc4dba15c6
  • Pointer size: 131 Bytes
  • Size of remote file: 117 kB
processor/docs/images/02-home-guide.svg ADDED
processor/docs/images/02-home.png ADDED

Git LFS Details

  • SHA256: 0e152cfca84a1a464f78d895f39c0cce000ebdd5eeb3666b310d613e242b718d
  • Pointer size: 130 Bytes
  • Size of remote file: 93.2 kB
processor/docs/images/README.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 教程截图
2
+
3
+ 请把脱敏后的页面截图放在这里,并使用以下文件名:
4
+
5
+ ```text
6
+ 01-start-success.png
7
+ 02-home.png
8
+ 03-upload.png
9
+ 04-workflow.png
10
+ 05-video-review.png
11
+ 06-review.png
12
+ 07-export.png
13
+ 08-failed.png
14
+ 09-trash.png
15
+ ```
16
+
17
+ 截图规则:
18
+
19
+ - 红色箭头:要点击的位置;
20
+ - 黄色框:需要注意的位置;
21
+ - 蓝色编号:操作顺序;
22
+ - 绿色框:成功状态;
23
+ - 不要保留密码、Token、API Key、服务器地址或真实隐私信息。
24
+
25
+ 如果暂时没有截图,教程中的“截图位置”文字可以保留,后续替换图片即可。
26
+
processor/docs/images/annotation-editor-annotated.png ADDED

Git LFS Details

  • SHA256: 5e22f878098f904f54ae45187f96b5dd940dd3b8790e95bebe90ac61faa71280
  • Pointer size: 132 Bytes
  • Size of remote file: 1.12 MB
processor/docs/images/annotation-editor-guide.svg ADDED
processor/docs/images/annotation-editor.png ADDED

Git LFS Details

  • SHA256: 789362a87e8ca8f6e67610c1e4a75950710608d8efd1560482c0bc7298ef79c1
  • Pointer size: 132 Bytes
  • Size of remote file: 1.03 MB
processor/docs/images/annotation-list-annotated.png ADDED

Git LFS Details

  • SHA256: ffb9aef51620f59bfa313e91624937dbae486f2b8490c282638b3287d84a9202
  • Pointer size: 130 Bytes
  • Size of remote file: 85.6 kB
processor/docs/images/annotation-list-guide.svg ADDED
processor/docs/images/annotation-list.png ADDED

Git LFS Details

  • SHA256: ce776976ee6cc2d5cb5132897243ec619b7b2c85fd9e98fe01649f09abb005c6
  • Pointer size: 130 Bytes
  • Size of remote file: 68.3 kB
processor/docs/images/approved-list-annotated.png ADDED

Git LFS Details

  • SHA256: 7802c831c89e1f70150a9f11040d245bdb3d81eeb44e602ce5bb187e0545d268
  • Pointer size: 131 Bytes
  • Size of remote file: 131 kB
processor/docs/images/approved-list-guide.svg ADDED
processor/docs/images/approved-list.png ADDED

Git LFS Details

  • SHA256: 3b5b0820bcc6b32b2c47405ce647ee50994ec441ad0a6aeb3cb79b7b2d229b73
  • Pointer size: 131 Bytes
  • Size of remote file: 103 kB
processor/docs/images/project-new-annotated.png ADDED

Git LFS Details

  • SHA256: 129d57668a48989c0b804c5ec12b054d3cedc9f50c690fa93ef4f6abef2a4c83
  • Pointer size: 131 Bytes
  • Size of remote file: 168 kB
processor/docs/images/project-new-guide.svg ADDED
processor/docs/images/project-new.png ADDED

Git LFS Details

  • SHA256: 880f7581c6f3fde15eadc3d9d63b86ce5e938c5ac1990b4f09bf2767a8d29aec
  • Pointer size: 131 Bytes
  • Size of remote file: 143 kB
processor/docs/images/project-workflow-select-annotated.png ADDED

Git LFS Details

  • SHA256: 4413bafb66c2be35c8a028887e9af98b19406f7a7e703a7cd41112e9573f10e2
  • Pointer size: 131 Bytes
  • Size of remote file: 187 kB
processor/docs/images/project-workflow-select-guide.svg ADDED