Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ Import dataset → Select checks and run scope → Run quality pipeline → Read

Quality checks include visual quality, sudden changes, state/action alignment, extreme values, metadata completeness, kinematic consistency, orientation alignment, and optional VLM task-success scoring. After the pipeline finishes, review the report dashboard, quality distribution, findings, signal charts, and recommended actions.

### Dynamic SE(3) kinematic consistency

Upload a URDF and use **Discover correction** to infer robot chains, joint/state mappings, logged pose fields, and quaternion/matrix/6D/rotation-vector/RPY conventions from structure and held-out geometry. RDS reports position residuals in metres and orientation residuals in radians/degrees separately, and can propose base, TCP, joint-sign, and joint-zero corrections. Ambiguous or under-excited data produces an evidence-backed refusal instead of a guessed mapping.

Proposals remain reviewable project metadata until accepted. Accepted, non-stale profiles drive filter previews and derived export pose fields; source observations are never overwritten. Every corrected export records the proposal ID, fingerprint, full profile, evidence, warnings, and before/after metrics in `conversion_report.json`. Manual link/index configuration remains available under **Manual configuration (advanced)**.

## Requirements

- Python 3.11+
Expand Down Expand Up @@ -135,7 +141,7 @@ Complete one end-to-end loop in this order:

1. **Import a dataset** — Enter the local dataset root in `Dataset path` (not a child file like `meta/info.json`) and click import. `Import format` defaults to `Auto detect`; pick a format manually if detection fails. Leading/trailing `'` or `"` in the path are stripped automatically.
2. **Choose run scope and checks** — In the sidebar, enable quality checks and choose whether to run on all episodes or only selected ones.
3. **Optional advanced setup** — Upload a URDF and configure joint mapping for kinematic consistency; enable task-success checks in `VLM settings` (requires an API key and `ffmpeg`).
3. **Optional advanced setup** — Upload a URDF and discover a kinematic correction, review held-out SE(3) evidence, then accept it; use manual mapping only as an advanced fallback. Enable task-success checks in `VLM settings` when needed (requires an API key and `ffmpeg`).
4. **Run the quality pipeline** — Click run and wait for progress to finish. Each episode is labeled passed, needs review, or excluded.
5. **Read the cleaning report** — Open the report page for quality distribution, findings, dataset signal charts, gripper curves, and recommended actions; download report JSON if needed.
6. **Locate problematic episodes** — From the report or sidebar, open issue/filter detail pages to inspect findings and parameters.
Expand All @@ -147,6 +153,7 @@ Complete one end-to-end loop in this order:
| Content | Path |
| --- | --- |
| Cleaning state | `.rds-artifacts/projects/<project_id>/cleaning_state.json` |
| Kinematic proposals | `.rds-artifacts/projects/<project_id>/filters/kinematic_proposals.json` |
| Rerun recordings | `.rds-artifacts/*.rrd` |
| Exports and conversion reports | `.rds-artifacts/` |

Expand Down Expand Up @@ -212,7 +219,7 @@ The PushT walkthrough and core API export flows pass without these optional depe

## API summary

Backend default: `http://127.0.0.1:8000`. Main endpoints: `/api/health`, `/api/formats`, `/api/projects`, `/api/projects/{id}/cleaning`, `/api/projects/{id}/exports`, `/api/artifacts/{filename}`. See `apps/api/main.py` for the full route list.
Backend default: `http://127.0.0.1:8000`. Main endpoints: `/api/health`, `/api/formats`, `/api/projects`, `/api/projects/{id}/cleaning`, `/api/projects/{id}/exports`, and `/api/projects/{id}/filters/kinematics/{discover,proposals}`. See `apps/api/main.py` for the full route list.

## Current limitations

Expand Down
11 changes: 9 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ RDS 在本机运行,数据集始终留在你的磁盘上,不上传云端。

当前质量检查包括视觉质量、突变检测、state/action 对齐、极值、元数据完整性、运动学一致性、姿态对齐,以及可选的 VLM 任务完成度检查。流水线结束后可查看报告仪表盘、质量分布、问题发现、信号曲线与建议操作。

### 动态 SE(3) 运动学一致性

上传 URDF 后点击 **自动发现修正**,RDS 会从机器人结构和 held-out 几何误差中识别运动链、joint/state 映射、末端 pose 字段,以及四元数、旋转矩阵、6D、旋转向量或 RPY 表示。位置残差以米显示,姿态残差分别以弧度和角度显示;系统可建议 base、TCP、关节正负号和零位 offset 修正。遇到映射歧义或运动激励不足时,会给出带证据的拒绝结果,不会猜 link 名或列号。

Proposal 在接受前只是可审查的项目元数据。只有已接受且未 stale 的 profile 才用于 filter 预览和导出中的派生末端 pose;源 observation 永远不会被覆盖。修正后的导出会在 `conversion_report.json` 中记录 proposal ID、fingerprint、完整 profile、证据、警告与 before/after 指标。手动 link/index 配置保留在 **手动配置(高级)** 中作为 fallback。

## 环境要求

- Python 3.11+
Expand Down Expand Up @@ -135,7 +141,7 @@ pnpm dev:web

1. **导入数据集** — 在 `Dataset path` 输入本地数据集根目录(不是 `meta/info.json` 这类子文件),点击导入。`Import format` 默认 `Auto detect`;识别失败时可手动选择格式。路径首尾多余的 `'` 或 `"` 会自动清理。
2. **选择运行范围与检查项** — 在侧栏勾选要执行的质量检查,并选择对全部 episode 还是仅选中 episode 运行流水线。
3. **可选高级配置** — 上传 URDF 并配置关节映射以启用运动学一致性检查;在 `VLM 设置` 中启用任务完成度检查(需要 API key 和 `ffmpeg`)。
3. **可选高级配置** — 上传 URDF,自动发现运动学修正,审查 held-out SE(3) 证据后再接受;手动映射仅作为高级 fallback。在 `VLM 设置` 中按需启用任务完成度检查(需要 API key 和 `ffmpeg`)。
4. **运行质量流水线** — 点击运行并等待进度完成。每个 episode 会被标记为通过、待审查或排除。
5. **阅读清洗报告** — 打开报告页查看质量分布、问题发现、数据集信号图、夹爪曲线与建议操作;可下载报告 JSON。
6. **定位问题 episode** — 从报告或侧栏进入问题/筛选详情页,查看具体 findings 与参数。
Expand All @@ -147,6 +153,7 @@ pnpm dev:web
| 内容 | 路径 |
| --- | --- |
| 清洗状态 | `.rds-artifacts/projects/<project_id>/cleaning_state.json` |
| 运动学 proposals | `.rds-artifacts/projects/<project_id>/filters/kinematic_proposals.json` |
| Rerun 录制 | `.rds-artifacts/*.rrd` |
| 导出数据与转换报告 | `.rds-artifacts/` |

Expand Down Expand Up @@ -212,7 +219,7 @@ PushT 教程与核心 API 导出流程不依赖上述可选项即可正常使用

## API 摘要

后端默认地址:`http://127.0.0.1:8000`。主要端点:`/api/health`、`/api/formats`、`/api/projects`、`/api/projects/{id}/cleaning`、`/api/projects/{id}/exports``/api/artifacts/{filename}`。完整路由见 `apps/api/main.py`。
后端默认地址:`http://127.0.0.1:8000`。主要端点:`/api/health`、`/api/formats`、`/api/projects`、`/api/projects/{id}/cleaning`、`/api/projects/{id}/exports`,以及 `/api/projects/{id}/filters/kinematics/{discover,proposals}`。完整路由见 `apps/api/main.py`。

## 当前限制

Expand Down
45 changes: 45 additions & 0 deletions apps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
FilterDetail,
FilterRun,
FilterSummary,
KinematicDiscoveryRequest,
KinematicProposalDecision,
VlmSettings,
)
from robot_data_studio.quality.kinematics import CorrectionProposal, DiscoveryRefusal
from robot_data_studio.reports import ReportSignals
from robot_data_studio.viewer import create_episode_recording

Expand Down Expand Up @@ -277,6 +280,48 @@ async def upload_filter_urdf(
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error

@app.post(
"/api/projects/{project_id}/filters/kinematics/discover",
status_code=201,
response_model=CorrectionProposal | DiscoveryRefusal,
)
def discover_kinematic_corrections(
project_id: str,
request: KinematicDiscoveryRequest,
) -> CorrectionProposal | DiscoveryRefusal:
try:
return service.discover_kinematic_corrections(project_id, request)
except KeyError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except (ValueError, OSError) as error:
raise HTTPException(status_code=400, detail=str(error)) from error

@app.get(
"/api/projects/{project_id}/filters/kinematics/proposals",
response_model=list[CorrectionProposal],
)
def kinematic_proposals(project_id: str) -> list[CorrectionProposal]:
try:
return service.kinematic_proposals(project_id)
except KeyError as error:
raise HTTPException(status_code=404, detail=str(error)) from error

@app.post(
"/api/projects/{project_id}/filters/kinematics/proposals/{proposal_id}/decision",
response_model=CorrectionProposal,
)
def decide_kinematic_proposal(
project_id: str,
proposal_id: str,
request: KinematicProposalDecision,
) -> CorrectionProposal:
try:
return service.decide_kinematic_proposal(project_id, proposal_id, request)
except KeyError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error)) from error

@app.get("/api/projects/{project_id}/vlm-settings", response_model=VlmSettings)
def vlm_settings(project_id: str) -> VlmSettings:
try:
Expand Down
153 changes: 153 additions & 0 deletions apps/web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,9 @@ test("opens the visual quality detail view from the filter label", async () => {
expect(screen.getByText("1 issue interval")).toBeInTheDocument();
expect(screen.getByText("1 / 1 affected")).toBeInTheDocument();
expect(screen.getByText("Pass with 1 low-rate anomaly")).toBeInTheDocument();
expect(screen.getAllByText("0.720").length).toBeGreaterThan(0);
expect(screen.getAllByText("4.0%").length).toBeGreaterThan(0);
expect(screen.getByText("Camera technical scores")).toBeInTheDocument();
expect(screen.getAllByText("Frame 2").length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: "Metrics & thresholds" })).toHaveAttribute(
"aria-expanded",
Expand Down Expand Up @@ -1828,6 +1831,85 @@ test("opens the kinematic consistency view with URDF configuration controls", as
expect(screen.getByText(/Pinocchio/)).toBeInTheDocument();
});

test("reviews and accepts a dynamic kinematic correction with SE3 evidence", async () => {
const user = userEvent.setup();
const proposal = kinematicProposalResponse();
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/formats") return jsonResponse(formatsResponse());
if (url === "/api/projects") return jsonResponse(projectResponse());
if (url.endsWith("/episodes?limit=500")) return jsonResponse(episodesResponse());
if (url.includes("/filters/kinematic_consistency/episodes/0")) {
const detail = filterDetailResponse("kinematic_consistency");
(detail.parameters as unknown as Record<string, unknown>).urdf_path = "/tmp/astral.urdf";
return jsonResponse(detail);
}
if (url.endsWith("/filters/kinematics/proposals")) return jsonResponse([proposal]);
if (url.endsWith(`/proposals/${proposal.id}/decision`)) {
const decision = JSON.parse(String(init?.body)).decision as "accept" | "reset";
return jsonResponse({
...proposal,
status: decision === "accept" ? "accepted" : "proposed",
});
}
throw new Error(`Unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetch);
vi.stubGlobal("confirm", vi.fn(() => true));
renderApp();

await importDatasetForTest(user);
await user.click(await screen.findByRole("button", { name: "Kinematic consistency" }));

expect(await screen.findByText("vector_mount")).toBeInTheDocument();
expect(screen.getByText("0.4000 m → 0.0010 m")).toBeInTheDocument();
expect(screen.getByText(/0.2000 rad .*11.46°.*0.0020 rad/)).toBeInTheDocument();
expect(screen.getByText("Selected by held-out SE(3) geometry.")).toBeInTheDocument();
expect(screen.getByText("Review base/TCP equivalence.")).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Accept correction" }));
expect(await screen.findByText("Accepted")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Reset" }));
expect(await screen.findByText("Proposed")).toBeInTheDocument();
const decisions = fetch.mock.calls
.filter(([url]) => String(url).endsWith(`/proposals/${proposal.id}/decision`))
.map(([, init]) => JSON.parse(String(init?.body)).decision);
expect(decisions).toEqual(["accept", "reset"]);
});

test("shows an ambiguous discovery refusal without an accept action", async () => {
const user = userEvent.setup();
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/formats") return jsonResponse(formatsResponse());
if (url === "/api/projects") return jsonResponse(projectResponse());
if (url.endsWith("/episodes?limit=500")) return jsonResponse(episodesResponse());
if (url.includes("/filters/kinematic_consistency/episodes/0")) {
const detail = filterDetailResponse("kinematic_consistency");
(detail.parameters as unknown as Record<string, unknown>).urdf_path = "/tmp/astral.urdf";
return jsonResponse(detail);
}
if (url.endsWith("/filters/kinematics/proposals")) return jsonResponse([]);
if (url.endsWith("/filters/kinematics/discover")) {
return jsonResponse({
reason: "ambiguous",
evidence: ["The top candidate margin is below the safety threshold."],
});
}
throw new Error(`Unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetch);
renderApp();

await importDatasetForTest(user);
await user.click(await screen.findByRole("button", { name: "Kinematic consistency" }));
await user.click(await screen.findByRole("button", { name: "Discover correction" }));

expect(await screen.findByText("Ambiguous mapping")).toBeInTheDocument();
expect(screen.getByText("The top candidate margin is below the safety threshold.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Accept correction" })).not.toBeInTheDocument();
});

test("restores configured kinematics from imported project filter config", async () => {
const user = userEvent.setup();
vi.stubGlobal(
Expand Down Expand Up @@ -3003,6 +3085,25 @@ function filterDetailResponse(
affected_camera_count: 1,
episode_frame_count: 1100,
episode_duration_seconds: 22,
technical_score: 0.72,
percentile: 0.04,
hard_failure: false,
hard_failure_reasons: [],
requested_backend: "auto",
effective_backend: "dover_mobile",
model_status: "scored",
artifact_fingerprint: "fixture",
camera_scores: [
{
camera: "cam_high",
technical_score: 0.72,
percentile: 0.04,
cohort_size: 25,
priority: "review",
model_status: "scored",
error_code: null,
},
],
incidents: [
{
id: "cam_high:blur:2:2",
Expand All @@ -3016,6 +3117,8 @@ function filterDetailResponse(
worst_value: 8.5,
threshold: 18,
representative_frames: [{ frame: 2, timestamp: 1 }],
severity: "warning",
hard_failure: false,
},
],
metrics: {
Expand Down Expand Up @@ -3109,3 +3212,53 @@ function filterDetailResponse(
skipped_reason: null,
};
}

function jsonResponse(body: unknown) {
return {
ok: true,
json: async () => body,
} as Response;
}

function kinematicProposalResponse() {
const summary = { mean: 0.1, median: 0.1, p95: 0.1, maximum: 0.1 };
return {
id: "proposal-123",
fingerprint: "fingerprint-123",
profile: {
urdf_path: "/tmp/astral.urdf",
fingerprint: "fingerprint-123",
chains: [
{
id: "arm",
end_effector_frame: "vector_mount",
joints: [{ urdf_joint: "alpha_axis", state_index: 0, sign: 1, zero_offset: 0 }],
logged_pose: {
position_indices: [2, 3, 4],
orientation_indices: [5, 6, 7, 8],
orientation_representation: "quaternion_xyzw",
},
base_transform: [],
tcp_transform: [],
},
],
},
before_training: { position: summary, orientation: summary, inlier_rate: 0.2 },
after_training: { position: summary, orientation: summary, inlier_rate: 0.98 },
before_held_out: {
position: { ...summary, p95: 0.4 },
orientation: { ...summary, p95: 0.2 },
inlier_rate: 0.2,
},
after_held_out: {
position: { ...summary, p95: 0.001 },
orientation: { ...summary, p95: 0.002 },
inlier_rate: 0.98,
},
confidence: 0.96,
candidate_margin: 0.42,
evidence: ["Selected by held-out SE(3) geometry."],
warnings: ["Review base/TCP equivalence."],
status: "proposed",
};
}
Loading