Spaces:
Running
Running
sync processor from GitLab 32d515b: glove display fixes, GPU acceleration, AI annotation propose-clip fix
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +3 -0
- processor/CHANGELOG.md +11 -0
- processor/README.md +35 -104
- processor/README.zh-CN.md +17 -13
- processor/app/ai_annotation.py +31 -2
- processor/app/api/projects.py +9 -0
- processor/app/browser_preview.py +390 -2
- processor/app/config.py +5 -5
- processor/app/database.py +4 -4
- processor/app/lerobot_export.py +82 -4
- processor/app/lerobot_v21.py +140 -5
- processor/app/localstore.py +3 -0
- processor/app/main.py +20 -2
- processor/app/media_cache.py +1 -1
- processor/app/paths.py +1 -0
- processor/app/processing/__init__.py +201 -201
- processor/app/processing/black_glove/README.md +1 -1
- processor/app/processing/black_glove/__init__.py +1 -1
- processor/app/processing/black_glove/d435_hands_demo.py +31 -13
- processor/app/processing/black_glove/d435_tracking.py +1 -0
- processor/app/processing/black_glove/pose_backends.py +10 -1
- processor/app/processing/modules/depth_hand_3d.py +1 -1
- processor/app/processing/modules/rgb_world_tracking.py +1 -1
- processor/app/routes/auth.py +3 -3
- processor/app/routes/export.py +172 -18
- processor/app/routes/ingestion.py +140 -42
- processor/app/routes/video.py +303 -22
- processor/app/version.py +2 -2
- processor/deploy.py +4 -4
- processor/deploy.sh +0 -0
- processor/docs/README.md +896 -0
- processor/docs/en/README.md +862 -0
- processor/docs/images/02-home-annotated.png +3 -0
- processor/docs/images/02-home-guide.svg +28 -0
- processor/docs/images/02-home.png +3 -0
- processor/docs/images/README.md +26 -0
- processor/docs/images/annotation-editor-annotated.png +3 -0
- processor/docs/images/annotation-editor-guide.svg +37 -0
- processor/docs/images/annotation-editor.png +3 -0
- processor/docs/images/annotation-list-annotated.png +3 -0
- processor/docs/images/annotation-list-guide.svg +22 -0
- processor/docs/images/annotation-list.png +3 -0
- processor/docs/images/approved-list-annotated.png +3 -0
- processor/docs/images/approved-list-guide.svg +31 -0
- processor/docs/images/approved-list.png +3 -0
- processor/docs/images/project-new-annotated.png +3 -0
- processor/docs/images/project-new-guide.svg +31 -0
- processor/docs/images/project-new.png +3 -0
- processor/docs/images/project-workflow-select-annotated.png +3 -0
- 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 |
-
#
|
| 2 |
|
| 3 |
[English](README.md) | [中文](README.zh-CN.md)
|
| 4 |
|
| 5 |
-
> Documentation: 2026-09-
|
| 6 |
|
| 7 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
<summary>Click to expand: source tree and runtime directories</summary>
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
|
| 65 |
```text
|
| 66 |
-
|
| 67 |
-
├── app/
|
| 68 |
-
|
| 69 |
-
|
| 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/
|
| 91 |
-
│ ├── static/
|
| 92 |
-
│
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 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 |
-
|
| 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 "
|
| 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 "
|
| 436 |
copy .env.example .env && deploy.bat
|
| 437 |
```
|
| 438 |
|
| 439 |
### Start the backend manually
|
| 440 |
|
| 441 |
```bash
|
| 442 |
-
cd "
|
| 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 "
|
| 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 "
|
| 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 `
|
| 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 "
|
| 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 "
|
| 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 |
-
#
|
| 2 |
|
| 3 |
[中文](README.zh-CN.md) | [English](README.md)
|
| 4 |
|
| 5 |
> 文档版本:2026-09-02 · 后端版本:`1.6.8` · WEB 前端版本:`1.5.6`
|
| 6 |
|
| 7 |
-
|
| 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 |
-
|
| 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 "
|
| 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 "
|
| 367 |
copy .env.example .env && deploy.bat
|
| 368 |
```
|
| 369 |
|
| 370 |
### 手动启动后端
|
| 371 |
|
| 372 |
```bash
|
| 373 |
-
cd "
|
| 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 "
|
| 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 "
|
| 401 |
npm ci
|
| 402 |
npm run dev
|
| 403 |
```
|
|
@@ -415,7 +419,7 @@ npm run build
|
|
| 415 |
<details>
|
| 416 |
<summary>点击展开:环境变量和安全配置</summary>
|
| 417 |
|
| 418 |
-
配置写入 `
|
| 419 |
|
| 420 |
| 配置 | 作用 |
|
| 421 |
| --- | --- |
|
|
@@ -472,7 +476,7 @@ GET /openapi.json
|
|
| 472 |
后端和 Worker 基础检查:
|
| 473 |
|
| 474 |
```bash
|
| 475 |
-
cd "
|
| 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 "
|
| 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 =
|
| 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,
|
| 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://
|
| 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 = "
|
| 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://
|
| 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
|
| 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", "
|
| 197 |
-
or "
|
| 198 |
-
password = os.environ.get("EGODATA_BOOTSTRAP_PASSWORD", "
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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":
|
| 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 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
|
|
|
|
|
|
|
|
|
| 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]
|
| 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="
|
| 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 =
|
| 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 `
|
| 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
|
| 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": "
|
| 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 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
self._preprocessor = _Preprocessor()
|
| 370 |
-
|
|
|
|
| 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
|
| 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
|
| 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 ``
|
| 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", "
|
| 22 |
-
or "
|
| 23 |
-
_BOOTSTRAP_PASSWORD = os.environ.get("EGODATA_BOOTSTRAP_PASSWORD", "
|
| 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 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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 |
-
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
| 559 |
raise HTTPException(status_code=404, detail="Export output not ready")
|
| 560 |
-
|
| 561 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
state["reader"].close()
|
| 127 |
-
state["reader"] =
|
| 128 |
-
|
|
|
|
| 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 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
for _, count_row in
|
| 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
|
|
|
|
| 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
|
| 1348 |
-
response is still the exact stored code domain (little-endian
|
| 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)
|
| 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={
|
| 1740 |
-
"Content-
|
| 1741 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 12 |
-
__version__ = "1.
|
|
|
|
| 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
|
| 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://
|
| 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
|
| 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
|
| 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 |
+

|
| 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)
|
| 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)
|
| 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)
|
| 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)
|
| 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)
|
| 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)
|
| 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 |
+
[](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)
|
| 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)
|
| 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)
|
| 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)
|
| 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)
|
| 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)
|
| 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 |
+

|
| 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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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 |
+
[](../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
|
processor/docs/images/02-home-guide.svg
ADDED
|
|
processor/docs/images/02-home.png
ADDED
|
Git LFS Details
|
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
|
processor/docs/images/annotation-editor-guide.svg
ADDED
|
|
processor/docs/images/annotation-editor.png
ADDED
|
Git LFS Details
|
processor/docs/images/annotation-list-annotated.png
ADDED
|
Git LFS Details
|
processor/docs/images/annotation-list-guide.svg
ADDED
|
|
processor/docs/images/annotation-list.png
ADDED
|
Git LFS Details
|
processor/docs/images/approved-list-annotated.png
ADDED
|
Git LFS Details
|
processor/docs/images/approved-list-guide.svg
ADDED
|
|
processor/docs/images/approved-list.png
ADDED
|
Git LFS Details
|
processor/docs/images/project-new-annotated.png
ADDED
|
Git LFS Details
|
processor/docs/images/project-new-guide.svg
ADDED
|
|
processor/docs/images/project-new.png
ADDED
|
Git LFS Details
|
processor/docs/images/project-workflow-select-annotated.png
ADDED
|
Git LFS Details
|
processor/docs/images/project-workflow-select-guide.svg
ADDED
|
|