diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b73f44f..4657454 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,8 +25,6 @@ jobs: - uses: Swatinem/rust-cache@v2 - uses: astral-sh/setup-uv@v9.0.0 - run: sudo apt-get update && sudo apt-get install -y doxygen - - run: uv tool install zensical - - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - run: docs/build.sh build - uses: actions/upload-pages-artifact@v5 with: diff --git a/README.md b/README.md index 92dc532..5bf74f3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ QuickIK provides high-level APIs for processing consecutive frames (i.e. with wa See the [docs site](https://nely-epfl.github.io/quickik/) for installation, usage examples, and benchmarks against KDL, Pinocchio, and RBDL. -Not yet published to crates.io or PyPI – see [`docs/installation.md`](docs/installation.md) for building from a local clone. +Not yet published to crates.io or PyPI – see [`docs/getting-started/installation.md`](docs/getting-started/installation.md) for building from a local clone. ## For developers @@ -21,8 +21,8 @@ cd devtools-pyenv && uv sync && source .venv/bin/activate ### Build - **Rust core**: `cargo build` (add `--workspace` to also build the `python`/`cpp` bindings crates and the `benchmark/` crates). -- **Python bindings**: `cd python && maturin develop --release` – see [`docs/installation.md`](docs/installation.md) for prerequisites. -- **C++ bindings**: CMake, from `cpp/` – see [`docs/installation.md`](docs/installation.md). +- **Python bindings**: `cd python && maturin develop --release` – see [`docs/getting-started/installation.md`](docs/getting-started/installation.md) for prerequisites. +- **C++ bindings**: CMake, from `cpp/` – see [`docs/getting-started/installation.md`](docs/getting-started/installation.md). ### Lint/format diff --git a/benchmark/README.md b/benchmark/README.md index 9b52b4e..47c749b 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,6 +1,6 @@ # QuickIK benchmark -Compares QuickIK's IK solve speed against KDL, Pinocchio, and RBDL, across QuickIK's Rust API, Python bindings, and C++ bindings, on two bodies: NeuroMechFly (a fly) and G1 (a Unitree humanoid). See the [Benchmarks docs page](../docs/benchmarks.md) for what's being compared, why, and the current results – this file only covers how to reproduce them. +Compares QuickIK's IK solve speed against KDL, Pinocchio, and RBDL, across QuickIK's Rust API, Python bindings, and C++ bindings, on two bodies: NeuroMechFly (a fly) and G1 (a Unitree humanoid). See the [Benchmarks docs page](../docs/technical/benchmarks.md) for what's being compared, why, and the current results – this file only covers how to reproduce them. ## Running it diff --git a/benchmark/assets/g1_body_plan.json b/benchmark/assets/g1_body_plan.json index 4005d40..d2aa2e2 100644 --- a/benchmark/assets/g1_body_plan.json +++ b/benchmark/assets/g1_body_plan.json @@ -1,7 +1,6 @@ { - "metadata": { - "name": "g1_29dof" - }, + "fixed_base": false, + "x-name": "g1_29dof", "joints": [ { "name": "pelvis", diff --git a/benchmark/assets/neuromechfly_ypr_legs.json b/benchmark/assets/neuromechfly_ypr_legs.json index ccdc52f..c9e964c 100644 --- a/benchmark/assets/neuromechfly_ypr_legs.json +++ b/benchmark/assets/neuromechfly_ypr_legs.json @@ -1 +1 @@ -{"metadata": {"name": "neuromechfly_ypr_legs"}, "joints": [{"name": "thorax", "parent": null, "offset_pos": [0.0, 0.0, 0.0], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "lf_thorax_coxa", "parent": "thorax", "offset_pos": [-0.161, 0.172, -0.23], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-lf_coxa-yaw", "axis": [1.0, 0.0, 0.0], "type": "hinge", "neutral": -0.29670597283903605, "limits": null}, {"name": "c_thorax-lf_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.33161255787892263, "limits": null}, {"name": "c_thorax-lf_coxa-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 0.5759586531581288, "limits": null}]}, {"name": "lf_coxa_trochanterfemur", "parent": "lf_thorax_coxa", "offset_pos": [-0.00181, -0.00172, -0.365], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lf_coxa-lf_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -2.426007660272118, "limits": null}, {"name": "lf_coxa-lf_trochanterfemur-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": -0.2617993877991494, "limits": null}]}, {"name": "lf_trochanterfemur_tibia", "parent": "lf_coxa_trochanterfemur", "offset_pos": [-0.00465, -0.00149, -0.705], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lf_trochanterfemur-lf_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.361356816555577, "limits": null}]}, {"name": "lf_tibia_tarsus", "parent": "lf_trochanterfemur_tibia", "offset_pos": [-0.000144, -0.000952, -0.518], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lf_tibia-lf_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.17453292519943295, "limits": null}]}, {"name": "lf_claw", "parent": "lf_tibia_tarsus", "offset_pos": [0.09203502219396992, 0.010583907142921345, -0.6712937582145876], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "lm_thorax_coxa", "parent": "thorax", "offset_pos": [-0.557, 0.125, -0.482], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-lm_coxa-yaw", "axis": [1.0, 0.0, 0.0], "type": "hinge", "neutral": -0.17453292519943295, "limits": null}, {"name": "c_thorax-lm_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.13962634015954636, "limits": null}, {"name": "c_thorax-lm_coxa-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 1.5184364492350666, "limits": null}]}, {"name": "lm_coxa_trochanterfemur", "parent": "lm_thorax_coxa", "offset_pos": [-0.000216, 0.000149, -0.181], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lm_coxa-lm_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -2.426007660272118, "limits": null}, {"name": "lm_coxa-lm_trochanterfemur-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 0.2617993877991494, "limits": null}]}, {"name": "lm_trochanterfemur_tibia", "parent": "lm_coxa_trochanterfemur", "offset_pos": [-0.000971, 5.52e-05, -0.784], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lm_trochanterfemur-lm_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.7976891295541595, "limits": null}]}, {"name": "lm_tibia_tarsus", "parent": "lm_trochanterfemur_tibia", "offset_pos": [6.58e-05, -0.00037, -0.667], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lm_tibia-lm_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.3490658503988659, "limits": null}]}, {"name": "lm_claw", "parent": "lm_tibia_tarsus", "offset_pos": [0.07429188105829865, 0.03035037624127261, -0.709561608388346], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "lh_thorax_coxa", "parent": "thorax", "offset_pos": [-0.784, 0.087, -0.497], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-lh_coxa-yaw", "axis": [1.0, 0.0, 0.0], "type": "hinge", "neutral": 0.05235987755982989, "limits": null}, {"name": "c_thorax-lh_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.4188790204786391, "limits": null}, {"name": "c_thorax-lh_coxa-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 2.3387411976724017, "limits": null}]}, {"name": "lh_coxa_trochanterfemur", "parent": "lh_thorax_coxa", "offset_pos": [-0.000177, -0.000227, -0.199], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lh_coxa-lh_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -1.9373154697137058, "limits": null}, {"name": "lh_coxa-lh_trochanterfemur-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 0.06981317007977318, "limits": null}]}, {"name": "lh_trochanterfemur_tibia", "parent": "lh_coxa_trochanterfemur", "offset_pos": [0.000108, 0.000294, -0.836], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lh_trochanterfemur-lh_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.7627825445142729, "limits": null}]}, {"name": "lh_tibia_tarsus", "parent": "lh_trochanterfemur_tibia", "offset_pos": [-8.94e-07, -0.000324, -0.684], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lh_tibia-lh_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.08726646259971647, "limits": null}]}, {"name": "lh_claw", "parent": "lh_tibia_tarsus", "offset_pos": [0.07590329969089399, 0.026866855276183835, -0.7977379203801962], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "rf_thorax_coxa", "parent": "thorax", "offset_pos": [-0.161, -0.172, -0.23], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-rf_coxa-yaw", "axis": [-1.0, 0.0, 0.0], "type": "hinge", "neutral": -0.29670597283903605, "limits": null}, {"name": "c_thorax-rf_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.33161255787892263, "limits": null}, {"name": "c_thorax-rf_coxa-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 0.5759586531581288, "limits": null}]}, {"name": "rf_coxa_trochanterfemur", "parent": "rf_thorax_coxa", "offset_pos": [-0.00181, 0.00172, -0.365], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rf_coxa-rf_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -2.426007660272118, "limits": null}, {"name": "rf_coxa-rf_trochanterfemur-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": -0.2617993877991494, "limits": null}]}, {"name": "rf_trochanterfemur_tibia", "parent": "rf_coxa_trochanterfemur", "offset_pos": [-0.00465, 0.00149, -0.705], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rf_trochanterfemur-rf_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.361356816555577, "limits": null}]}, {"name": "rf_tibia_tarsus", "parent": "rf_trochanterfemur_tibia", "offset_pos": [-0.000144, 0.000952, -0.518], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rf_tibia-rf_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.17453292519943295, "limits": null}]}, {"name": "rf_claw", "parent": "rf_tibia_tarsus", "offset_pos": [0.09203502219396992, -0.010583907142921345, -0.6712937582145876], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "rm_thorax_coxa", "parent": "thorax", "offset_pos": [-0.557, -0.125, -0.482], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-rm_coxa-yaw", "axis": [-1.0, 0.0, 0.0], "type": "hinge", "neutral": -0.17453292519943295, "limits": null}, {"name": "c_thorax-rm_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.13962634015954636, "limits": null}, {"name": "c_thorax-rm_coxa-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 1.5184364492350666, "limits": null}]}, {"name": "rm_coxa_trochanterfemur", "parent": "rm_thorax_coxa", "offset_pos": [-0.000216, -0.000149, -0.181], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rm_coxa-rm_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -2.426007660272118, "limits": null}, {"name": "rm_coxa-rm_trochanterfemur-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 0.2617993877991494, "limits": null}]}, {"name": "rm_trochanterfemur_tibia", "parent": "rm_coxa_trochanterfemur", "offset_pos": [-0.000971, -5.52e-05, -0.784], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rm_trochanterfemur-rm_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.7976891295541595, "limits": null}]}, {"name": "rm_tibia_tarsus", "parent": "rm_trochanterfemur_tibia", "offset_pos": [6.58e-05, 0.00037, -0.667], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rm_tibia-rm_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.3490658503988659, "limits": null}]}, {"name": "rm_claw", "parent": "rm_tibia_tarsus", "offset_pos": [0.07429188105829866, -0.03035037624127261, -0.709561608388346], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "rh_thorax_coxa", "parent": "thorax", "offset_pos": [-0.784, -0.087, -0.497], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-rh_coxa-yaw", "axis": [-1.0, 0.0, 0.0], "type": "hinge", "neutral": 0.05235987755982989, "limits": null}, {"name": "c_thorax-rh_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.4188790204786391, "limits": null}, {"name": "c_thorax-rh_coxa-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 2.3387411976724017, "limits": null}]}, {"name": "rh_coxa_trochanterfemur", "parent": "rh_thorax_coxa", "offset_pos": [-0.000177, 0.000227, -0.199], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rh_coxa-rh_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -1.9373154697137058, "limits": null}, {"name": "rh_coxa-rh_trochanterfemur-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 0.06981317007977318, "limits": null}]}, {"name": "rh_trochanterfemur_tibia", "parent": "rh_coxa_trochanterfemur", "offset_pos": [0.000108, -0.000294, -0.836], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rh_trochanterfemur-rh_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.7627825445142729, "limits": null}]}, {"name": "rh_tibia_tarsus", "parent": "rh_trochanterfemur_tibia", "offset_pos": [-8.94e-07, 0.000324, -0.684], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rh_tibia-rh_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.08726646259971647, "limits": null}]}, {"name": "rh_claw", "parent": "rh_tibia_tarsus", "offset_pos": [0.07590329969089399, -0.026866855276183835, -0.7977379203801962], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}]} \ No newline at end of file +{"fixed_base": false, "x-name": "neuromechfly_ypr_legs", "joints": [{"name": "thorax", "parent": null, "offset_pos": [0.0, 0.0, 0.0], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "lf_thorax_coxa", "parent": "thorax", "offset_pos": [-0.161, 0.172, -0.23], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-lf_coxa-yaw", "axis": [1.0, 0.0, 0.0], "type": "hinge", "neutral": -0.29670597283903605, "limits": null}, {"name": "c_thorax-lf_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.33161255787892263, "limits": null}, {"name": "c_thorax-lf_coxa-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 0.5759586531581288, "limits": null}]}, {"name": "lf_coxa_trochanterfemur", "parent": "lf_thorax_coxa", "offset_pos": [-0.00181, -0.00172, -0.365], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lf_coxa-lf_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -2.426007660272118, "limits": null}, {"name": "lf_coxa-lf_trochanterfemur-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": -0.2617993877991494, "limits": null}]}, {"name": "lf_trochanterfemur_tibia", "parent": "lf_coxa_trochanterfemur", "offset_pos": [-0.00465, -0.00149, -0.705], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lf_trochanterfemur-lf_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.361356816555577, "limits": null}]}, {"name": "lf_tibia_tarsus", "parent": "lf_trochanterfemur_tibia", "offset_pos": [-0.000144, -0.000952, -0.518], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lf_tibia-lf_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.17453292519943295, "limits": null}]}, {"name": "lf_claw", "parent": "lf_tibia_tarsus", "offset_pos": [0.09203502219396992, 0.010583907142921345, -0.6712937582145876], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "lm_thorax_coxa", "parent": "thorax", "offset_pos": [-0.557, 0.125, -0.482], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-lm_coxa-yaw", "axis": [1.0, 0.0, 0.0], "type": "hinge", "neutral": -0.17453292519943295, "limits": null}, {"name": "c_thorax-lm_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.13962634015954636, "limits": null}, {"name": "c_thorax-lm_coxa-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 1.5184364492350666, "limits": null}]}, {"name": "lm_coxa_trochanterfemur", "parent": "lm_thorax_coxa", "offset_pos": [-0.000216, 0.000149, -0.181], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lm_coxa-lm_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -2.426007660272118, "limits": null}, {"name": "lm_coxa-lm_trochanterfemur-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 0.2617993877991494, "limits": null}]}, {"name": "lm_trochanterfemur_tibia", "parent": "lm_coxa_trochanterfemur", "offset_pos": [-0.000971, 5.52e-05, -0.784], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lm_trochanterfemur-lm_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.7976891295541595, "limits": null}]}, {"name": "lm_tibia_tarsus", "parent": "lm_trochanterfemur_tibia", "offset_pos": [6.58e-05, -0.00037, -0.667], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lm_tibia-lm_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.3490658503988659, "limits": null}]}, {"name": "lm_claw", "parent": "lm_tibia_tarsus", "offset_pos": [0.07429188105829865, 0.03035037624127261, -0.709561608388346], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "lh_thorax_coxa", "parent": "thorax", "offset_pos": [-0.784, 0.087, -0.497], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-lh_coxa-yaw", "axis": [1.0, 0.0, 0.0], "type": "hinge", "neutral": 0.05235987755982989, "limits": null}, {"name": "c_thorax-lh_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.4188790204786391, "limits": null}, {"name": "c_thorax-lh_coxa-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 2.3387411976724017, "limits": null}]}, {"name": "lh_coxa_trochanterfemur", "parent": "lh_thorax_coxa", "offset_pos": [-0.000177, -0.000227, -0.199], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lh_coxa-lh_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -1.9373154697137058, "limits": null}, {"name": "lh_coxa-lh_trochanterfemur-roll", "axis": [0.0, 0.0, 1.0], "type": "hinge", "neutral": 0.06981317007977318, "limits": null}]}, {"name": "lh_trochanterfemur_tibia", "parent": "lh_coxa_trochanterfemur", "offset_pos": [0.000108, 0.000294, -0.836], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lh_trochanterfemur-lh_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.7627825445142729, "limits": null}]}, {"name": "lh_tibia_tarsus", "parent": "lh_trochanterfemur_tibia", "offset_pos": [-8.94e-07, -0.000324, -0.684], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "lh_tibia-lh_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.08726646259971647, "limits": null}]}, {"name": "lh_claw", "parent": "lh_tibia_tarsus", "offset_pos": [0.07590329969089399, 0.026866855276183835, -0.7977379203801962], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "rf_thorax_coxa", "parent": "thorax", "offset_pos": [-0.161, -0.172, -0.23], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-rf_coxa-yaw", "axis": [-1.0, 0.0, 0.0], "type": "hinge", "neutral": -0.29670597283903605, "limits": null}, {"name": "c_thorax-rf_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.33161255787892263, "limits": null}, {"name": "c_thorax-rf_coxa-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 0.5759586531581288, "limits": null}]}, {"name": "rf_coxa_trochanterfemur", "parent": "rf_thorax_coxa", "offset_pos": [-0.00181, 0.00172, -0.365], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rf_coxa-rf_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -2.426007660272118, "limits": null}, {"name": "rf_coxa-rf_trochanterfemur-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": -0.2617993877991494, "limits": null}]}, {"name": "rf_trochanterfemur_tibia", "parent": "rf_coxa_trochanterfemur", "offset_pos": [-0.00465, 0.00149, -0.705], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rf_trochanterfemur-rf_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.361356816555577, "limits": null}]}, {"name": "rf_tibia_tarsus", "parent": "rf_trochanterfemur_tibia", "offset_pos": [-0.000144, 0.000952, -0.518], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rf_tibia-rf_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.17453292519943295, "limits": null}]}, {"name": "rf_claw", "parent": "rf_tibia_tarsus", "offset_pos": [0.09203502219396992, -0.010583907142921345, -0.6712937582145876], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "rm_thorax_coxa", "parent": "thorax", "offset_pos": [-0.557, -0.125, -0.482], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-rm_coxa-yaw", "axis": [-1.0, 0.0, 0.0], "type": "hinge", "neutral": -0.17453292519943295, "limits": null}, {"name": "c_thorax-rm_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.13962634015954636, "limits": null}, {"name": "c_thorax-rm_coxa-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 1.5184364492350666, "limits": null}]}, {"name": "rm_coxa_trochanterfemur", "parent": "rm_thorax_coxa", "offset_pos": [-0.000216, -0.000149, -0.181], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rm_coxa-rm_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -2.426007660272118, "limits": null}, {"name": "rm_coxa-rm_trochanterfemur-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 0.2617993877991494, "limits": null}]}, {"name": "rm_trochanterfemur_tibia", "parent": "rm_coxa_trochanterfemur", "offset_pos": [-0.000971, -5.52e-05, -0.784], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rm_trochanterfemur-rm_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.7976891295541595, "limits": null}]}, {"name": "rm_tibia_tarsus", "parent": "rm_trochanterfemur_tibia", "offset_pos": [6.58e-05, 0.00037, -0.667], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rm_tibia-rm_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.3490658503988659, "limits": null}]}, {"name": "rm_claw", "parent": "rm_tibia_tarsus", "offset_pos": [0.07429188105829866, -0.03035037624127261, -0.709561608388346], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}, {"name": "rh_thorax_coxa", "parent": "thorax", "offset_pos": [-0.784, -0.087, -0.497], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "c_thorax-rh_coxa-yaw", "axis": [-1.0, 0.0, 0.0], "type": "hinge", "neutral": 0.05235987755982989, "limits": null}, {"name": "c_thorax-rh_coxa-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 0.4188790204786391, "limits": null}, {"name": "c_thorax-rh_coxa-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 2.3387411976724017, "limits": null}]}, {"name": "rh_coxa_trochanterfemur", "parent": "rh_thorax_coxa", "offset_pos": [-0.000177, 0.000227, -0.199], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rh_coxa-rh_trochanterfemur-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -1.9373154697137058, "limits": null}, {"name": "rh_coxa-rh_trochanterfemur-roll", "axis": [0.0, 0.0, -1.0], "type": "hinge", "neutral": 0.06981317007977318, "limits": null}]}, {"name": "rh_trochanterfemur_tibia", "parent": "rh_coxa_trochanterfemur", "offset_pos": [0.000108, -0.000294, -0.836], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rh_trochanterfemur-rh_tibia-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": 1.7627825445142729, "limits": null}]}, {"name": "rh_tibia_tarsus", "parent": "rh_trochanterfemur_tibia", "offset_pos": [-8.94e-07, 0.000324, -0.684], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": [{"name": "rh_tibia-rh_tarsus1-pitch", "axis": [0.0, 1.0, 0.0], "type": "hinge", "neutral": -0.08726646259971647, "limits": null}]}, {"name": "rh_claw", "parent": "rh_tibia_tarsus", "offset_pos": [0.07590329969089399, -0.026866855276183835, -0.7977379203801962], "offset_quat": [1.0, 0.0, 0.0, 0.0], "dofs": []}]} diff --git a/benchmark/extern/kdl/README.md b/benchmark/extern/kdl/README.md index 0a13728..3e3f7f9 100644 --- a/benchmark/extern/kdl/README.md +++ b/benchmark/extern/kdl/README.md @@ -1,6 +1,6 @@ # KDL benchmark -Benchmarks Orocos KDL's tree-based inverse kinematics against QuickIK, on both bodies (see `../../README.md`). See the [Benchmarks docs page](../../../docs/benchmarks.md) for KDL's modeling compromises and results; `bench_kdl.cpp`'s header comment has the full write-up. +Benchmarks Orocos KDL's tree-based inverse kinematics against QuickIK, on both bodies (see `../../README.md`). See the [Benchmarks docs page](../../../docs/technical/benchmarks.md) for KDL's modeling compromises and results; `bench_kdl.cpp`'s header comment has the full write-up. ## Build diff --git a/benchmark/extern/kdl/bench_kdl.cpp b/benchmark/extern/kdl/bench_kdl.cpp index 38178d2..331e5a5 100644 --- a/benchmark/extern/kdl/bench_kdl.cpp +++ b/benchmark/extern/kdl/bench_kdl.cpp @@ -396,23 +396,12 @@ void write_results_json(const std::string &body, double single_frame_latency_us, "uses simple contiguous chunking (8 independent, " "internally-warm-started, externally-cold-started chunks), not " "QuickIK's overlap-stitched segmented solve, since KDL has no " - "parallel solve path to mirror. single_frame_latency_max_us forces " - "max_iter=10 (not this file's own kMaxIter=100 ceiling) so the " - "forced-worst-case number is comparable to quickik/RBDL/Pinocchio's, " - "which all cap at 10."; - if (body == "g1") { - notes += - " Unlike the fly, G1's step-size early-stop NEVER triggers within " - "kMaxIter=100 -- every single_frame_latency_us call burns the full " - "100 iterations, landing at a real but looser ~3-4mm position " - "residual (vs. the fly's ~10-micron near-exact fit), which is why " - "single_frame_latency_us (100 real iterations) is larger than " - "single_frame_latency_max_us (only 10 forced iterations) for this " - "body -- backwards from every other body/library combination in " - "this comparison, and not a bug: it reflects KDL's own actual " - "default behavior being worse than the shared 10-iteration cap on " - "this harder problem, not an inconsistency in the measurement."; - } + "parallel solve path to mirror. Both single_frame_latency_us and " + "single_frame_latency_max_us force max_iter=10 (not this file's own " + "kMaxIter=100 ceiling, which only bounds the warm-started throughput " + "sequence below) so both are comparable to quickik/RBDL/Pinocchio's, " + "which also cap at 10; only step_tol differs (kStepTol vs. 0, i.e. " + "early stopping allowed vs. forced to the full 10 iterations)."; out << "{\n" << " \"name\": \"kdl\",\n" << " \"body\": \"" << body << "\",\n" @@ -457,13 +446,17 @@ void run_body(const BodyConfig &body, const std::filesystem::path &assets_dir) { auto target = to_vec3s(fixtures["synthetic_frames"][0]["target_ego"]); Frames target_frames = build_target_frames(sb.model, target); + // max_iter is forced to 10 here (not this file's own kMaxIter=100) to + // match QuickIK/RBDL/Pinocchio's shared iteration cap -- early stopping + // (step_tol=kStepTol, the default) still applies within that budget. std::printf("-- single-frame time (latency) --\n"); - double single_frame_latency_us = summarize( - "CartToJnt() (cold)", bench_single_frame_latency(sb, q_neutral, target_frames, kLatencyNCalls, 500)); + double single_frame_latency_us = + summarize("CartToJnt() (cold)", + bench_single_frame_latency(sb, q_neutral, target_frames, kLatencyNCalls, 500, /*max_iter=*/10)); - // step_tol=0 disables early stopping; max_iter is forced to 10 here (not - // this file's own kMaxIter=100) to match QuickIK/RBDL/Pinocchio's shared - // iteration cap -- the worst case if a frame never converges early. + // step_tol=0 additionally disables early stopping, forcing every solve to + // run the full 10 iterations -- the worst case if a frame never converges + // early. std::printf("\n-- single-frame time (latency), early stop disabled (10 iterations) --\n"); double single_frame_latency_max_us = summarize("CartToJnt() (forced max iterations)", diff --git a/benchmark/extern/pinocchio/README.md b/benchmark/extern/pinocchio/README.md index a8a4a10..1522cbc 100644 --- a/benchmark/extern/pinocchio/README.md +++ b/benchmark/extern/pinocchio/README.md @@ -1,6 +1,6 @@ # Pinocchio benchmark -Benchmarks [Pinocchio](https://github.com/stack-of-tasks/pinocchio) against QuickIK, on both bodies (see `../../README.md`). Methodology mirrors `../../quickik_python/bench.py` and `../../quickik_rust/src/perf.rs` exactly (same fixtures, same metrics, same config values) so the numbers are directly comparable. See the [Benchmarks docs page](../../../docs/benchmarks.md) for Pinocchio's modeling compromises and results. +Benchmarks [Pinocchio](https://github.com/stack-of-tasks/pinocchio) against QuickIK, on both bodies (see `../../README.md`). Methodology mirrors `../../quickik_python/bench.py` and `../../quickik_rust/src/perf.rs` exactly (same fixtures, same metrics, same config values) so the numbers are directly comparable. See the [Benchmarks docs page](../../../docs/technical/benchmarks.md) for Pinocchio's modeling compromises and results. ## Running diff --git a/benchmark/extern/rbdl/README.md b/benchmark/extern/rbdl/README.md index 023ac71..97b02a9 100644 --- a/benchmark/extern/rbdl/README.md +++ b/benchmark/extern/rbdl/README.md @@ -1,6 +1,6 @@ # RBDL benchmark -Benchmarks RBDL's core-library `InverseKinematicsConstraintSet` (not an addon – see `rbdl-src/include/rbdl/Kinematics.h`) against QuickIK, on both bodies (see `../../README.md`). See the [Benchmarks docs page](../../../docs/benchmarks.md) for RBDL's modeling compromises and results; `bench_rbdl.cpp`'s header comment has the full write-up. `leg_poc.cpp` is the earlier one-leg proof of concept this was built up from. +Benchmarks RBDL's core-library `InverseKinematicsConstraintSet` (not an addon – see `rbdl-src/include/rbdl/Kinematics.h`) against QuickIK, on both bodies (see `../../README.md`). See the [Benchmarks docs page](../../../docs/technical/benchmarks.md) for RBDL's modeling compromises and results; `bench_rbdl.cpp`'s header comment has the full write-up. `leg_poc.cpp` is the earlier one-leg proof of concept this was built up from. ## Build diff --git a/benchmark/plot/plot_comparison.py b/benchmark/plot/plot_comparison.py index 7727892..0f4bae4 100644 --- a/benchmark/plot/plot_comparison.py +++ b/benchmark/plot/plot_comparison.py @@ -79,16 +79,6 @@ }, ] -# Metric key -> the library whose bar, in *both* bodies, should be drawn -# capped at CAP_MULTIPLE-x the next-highest bar's value (the max across -# every other library's bar in either body) instead of running out to its -# true value, which would flatten every other bar down near zero on a -# linear axis -- see `draw_body_bars`. Only KDL's mean-latency result is -# extreme enough (up to ~60x the next-highest bar, for g1) to need this; -# everywhere else a plain, uncapped bar is clearer. -CAPPED_METRIC_BARS = {"single_frame_latency_us": "kdl"} -CAP_MULTIPLE = 2 - # Explicit display order (not alphabetical): language variants of the same # library grouped together, fastest-per-library first. ORDER = [ @@ -155,7 +145,14 @@ def load_results(): data = json.loads(path.read_text()) # quickik-scaling.json is a list of weak-scaling data points (see # plot_scaling.py), not a single-library result -- skip it here. - if isinstance(data, dict): + # errors-.json is plot_2d_comparison.py's own per-frame + # fit-residual distribution data (no "name"/"formulation" keys), + # not a per-library result either -- skip that too. So is + # quickik-rust-2d-xyview-.json (an "observation" override of + # quickik-rust's own 3D result, also only used by + # plot_2d_comparison.py) -- without this it'd show up here as a + # second, indistinguishable "quickik-rust" row. + if isinstance(data, dict) and "name" in data and "observation" not in data: all_results.append(data) included = [r for r in all_results if r.get("formulation") == "whole-tree"] excluded = [r for r in all_results if r not in included] @@ -303,24 +300,6 @@ def register_fonts(): BAR_OFFSET = 0.20 BAR_HEIGHT = 0.40 -# A capped bar's "keeps going" dashes past its tip (see draw_body_bars): -# DASH_COUNT blocks, each DASH_WIDTH*cap_length wide, separated by -# DASH_GAP*cap_length (also used as the gap right after the bar's tip). -# Sized so the whole run -- DASH_GAP + DASH_COUNT*DASH_WIDTH + -# (DASH_COUNT-1)*DASH_GAP, as a fraction of cap_length -- lands comfortably -# inside XLIM_PAD's headroom past the widest bar. -DASH_COUNT = 5 -DASH_WIDTH = 0.012 -DASH_GAP = 0.01 - - -def dash_positions(cap_length): - """Left-edge x-positions for DASH_COUNT dashes continuing a capped bar - of length `cap_length` past its own tip.""" - step = (DASH_WIDTH + DASH_GAP) * cap_length - first = cap_length + DASH_GAP * cap_length - return [first + i * step for i in range(DASH_COUNT)] - def draw_body_bars( ax, @@ -332,37 +311,18 @@ def draw_body_bars( unit, small_unit, color, - cap_lib=None, - cap_length=None, ): """Draws one body's bars (one per library in `lib_names`, at the matching `y_positions`) on `ax`, skipping libraries with no value for `key`, and annotates each with its value (see `format_value`) just past its tip -- plain "xxx unit", no parens. - If `cap_lib` names one of `lib_names` and `cap_length` is given, that - bar is instead drawn at the (already-scaled) `cap_length` rather than - its true length -- since running it out to its true value would flatten - every other bar down near zero on a linear axis. The caller computes - `cap_length` once per metric (shared across both bodies -- see - CAPPED_METRIC_BARS), so both bodies' bars for `cap_lib` end up the same - length even though their true values differ. That one bar's true value - plus "(capped in chart)" is written inside it, and a few small - same-color, same-height blocks continue past its tip (see - dash_positions()) -- a dashed-line cue that the bar keeps going -- in - place of the plain label every other bar gets there. Those blocks are - deliberately excluded from this function's returned widest extent (they - size themselves off of it, via dash_positions(cap_length)), so they land - inside the caller's XLIM_PAD headroom rather than pushing it out further. - Every QuickIK bar is drawn at QUICKIK_ALPHA with its value label bold; every other library's bar gets OTHER_ALPHA and a plain-weight label. Returns the widest x-extent actually drawn (unpadded, so the caller decides how much room to leave past it), or 0.0 if no library had data. """ - from matplotlib.patches import Rectangle - present = [ (y, lib, results_by_name[lib][key] * scale) for y, lib in zip(y_positions, lib_names, strict=True) @@ -373,44 +333,14 @@ def draw_body_bars( ys = [y for y, _, _ in present] values = [v for _, _, v in present] - display_values = list(values) - cap_idx = next((i for i, (_, lib, _) in enumerate(present) if lib == cap_lib), None) - if cap_idx is not None and cap_length is not None: - display_values[cap_idx] = cap_length - bars = ax.barh(ys, display_values, height=BAR_HEIGHT, color=color, zorder=3) - for i, (bar, (_, lib, _)) in enumerate(zip(bars, present, strict=True)): + bars = ax.barh(ys, values, height=BAR_HEIGHT, color=color, zorder=3) + for bar, (_, lib, value) in zip(bars, present, strict=True): is_quickik = lib.startswith("quickik-") bar.set_alpha(QUICKIK_ALPHA if is_quickik else OTHER_ALPHA) weight = "bold" if is_quickik else "normal" - text = format_value(values[i], unit, small_unit) - if i == cap_idx and cap_length is not None: - ax.annotate( - f"{text} (capped in chart)", - (bar.get_width(), bar.get_y() + bar.get_height() / 2), - xytext=(-4, 0), - fontsize=TEXT_NUMBER_FONTSIZE, - fontweight=weight, - textcoords="offset points", - ha="right", - va="center", - zorder=4, - ) - for dash_x in dash_positions(cap_length): - ax.add_patch( - Rectangle( - (dash_x, bar.get_y()), - DASH_WIDTH * cap_length, - bar.get_height(), - facecolor=color, - alpha=bar.get_alpha(), - edgecolor="none", - zorder=3, - ) - ) - continue ax.annotate( - text, + format_value(value, unit, small_unit), (bar.get_width(), bar.get_y() + bar.get_height() / 2), xytext=(4, 0), fontsize=TEXT_NUMBER_FONTSIZE, @@ -420,7 +350,7 @@ def draw_body_bars( va="center", zorder=4, ) - return max(display_values) + return max(values) def plot_chart(results_by_body): @@ -483,18 +413,6 @@ def plot_chart(results_by_body): metric["scale"], ) - cap_lib = CAPPED_METRIC_BARS.get(key) - cap_length = None - if cap_lib: - other_values = [ - r[key] - for by_name in (nmf_by_name, g1_by_name) - for name, r in by_name.items() - if name != cap_lib and r.get(key) is not None - ] - if other_values: - cap_length = CAP_MULTIPLE * max(other_values) * scale - nmf_ys = [y + BAR_OFFSET for y in group_ys] g1_ys = [y - BAR_OFFSET for y in group_ys] widest_nmf = draw_body_bars( @@ -507,8 +425,6 @@ def plot_chart(results_by_body): unit, small_unit, NEUROMECHFLY_COLOR, - cap_lib, - cap_length, ) widest_g1 = draw_body_bars( ax, @@ -520,8 +436,6 @@ def plot_chart(results_by_body): unit, small_unit, G1_COLOR, - cap_lib, - cap_length, ) widest = max(widest_nmf, widest_g1) if widest == 0.0: @@ -546,10 +460,6 @@ def plot_chart(results_by_body): label.set_fontweight("bold") ax.set_ylim(-0.5, len(ordered_libs) - 1 + 0.5) despine(ax) - # Even a capped bar's tip gets the usual padding now: its "..." - # label past the tip (see draw_body_bars) needs the room, and the - # in-bar "(capped in chart)" text already marks it as truncated, so - # a flush-to-the-edge tip is no longer the only cue for that. ax.set_xlim(0, widest * XLIM_PAD) legend_handles = [ diff --git a/benchmark/plot/render_video.py b/benchmark/plot/render_video.py index 6de46f2..b9a7bdc 100644 --- a/benchmark/plot/render_video.py +++ b/benchmark/plot/render_video.py @@ -235,7 +235,7 @@ def solve_sequence(tree, fixtures, missing_keypoints=(), weight=None): config = ( quickik.SolverConfig() if weight is None - else quickik.SolverConfig(weight=weight) + else quickik.SolverConfig(neutral_weight=weight) ) seq = quickik.SequenceSolver(tree, config) return [ diff --git a/benchmark/plot/render_video_2d.py b/benchmark/plot/render_video_2d.py index 6fa8b19..3e2990b 100644 --- a/benchmark/plot/render_video_2d.py +++ b/benchmark/plot/render_video_2d.py @@ -95,7 +95,7 @@ def solve_sequence_xyview(tree, fixtures): """Warm-started XYView solve, mirroring `render_video.solve_sequence` but with a `quickik.XYView()` mapper, 2D observations, and a stronger neutral-pose prior -- see `WEIGHT_2D_XYVIEW`.""" - config = quickik.SolverConfig(weight=WEIGHT_2D_XYVIEW) + config = quickik.SolverConfig(neutral_weight=WEIGHT_2D_XYVIEW) seq = quickik.SequenceSolver(tree, config, mapper=quickik.XYView()) return [ seq.solve_frame(build_observations_2d_xyview(f["target_ego"])) @@ -325,7 +325,7 @@ def bones(key, color, width, alpha): body["lo"][2], "X-Y projection", size=x_range * 0.06, - color="0.4", + color="0.7", ) return artists diff --git a/benchmark/plot/results/comparison-2d.svg b/benchmark/plot/results/comparison-2d.svg index 7a94b55..ae14701 100644 --- a/benchmark/plot/results/comparison-2d.svg +++ b/benchmark/plot/results/comparison-2d.svg @@ -6,7 +6,7 @@ - 2026-07-25T03:40:12.084112 + 2026-07-27T01:01:04.649962 image/svg+xml @@ -41,12 +41,12 @@ z - - + @@ -56,100 +56,110 @@ L 0 3.5 - + - 0.01 + 0.01 - + - 0.02 + 0.02 - + - 0.03 + 0.03 - + - 0.04 + 0.04 - + - 0.05 + 0.05 - + - 0.06 + 0.06 - + - 0.07 + 0.07 - + + + + + + + + 0.08 + + + Latency (ms) - + - - + - + 3D - + - + - + XYView @@ -170,24 +180,24 @@ L 339.01875 73.334767 L 339.01875 60.609353 L 47.41875 60.609353 z -" clip-path="url(#p384aec4d75)" style="fill: #4051b5"/> +" clip-path="url(#p1ec19649fb)" style="fill: #4051b5"/> - - - Mean single-frame latency (with early stopping) +" clip-path="url(#p1ec19649fb)" style="fill: #2fb170"/> - 66.3 μs + Mean single-frame latency (with early stopping) - 57.8 μs + 66.8 μs + + + 58.6 μs @@ -200,108 +210,108 @@ z " style="fill: #ffffff"/> - - - - - - - - 0.00 - - - + - 0.02 + 0.00 - + - 0.04 + 0.02 - + - 0.06 + 0.04 - + - 0.08 + 0.06 - + - 0.10 + 0.08 - + - 0.12 + 0.10 - + - 0.14 + 0.12 + + + + + + + + + + 0.14 - + Latency (ms) - + - + - + 3D - + - + - + XYView @@ -322,24 +332,24 @@ L 339.01875 176.410617 L 339.01875 163.685203 L 47.41875 163.685203 z -" clip-path="url(#p653fc14074)" style="fill: #4051b5"/> +" clip-path="url(#p6bb2df0dac)" style="fill: #4051b5"/> - - - Single-frame latency (fixed 10 iterations) +" clip-path="url(#p6bb2df0dac)" style="fill: #2fb170"/> - 132 μs + Single-frame latency (fixed 10 iterations) - 114 μs + 133 μs + + + 115 μs @@ -352,118 +362,118 @@ z " style="fill: #ffffff"/> - - - - - - - - 0.0 - - - + - 2.5 + 0.0 - + - 5.0 + 2.5 - + - 7.5 + 5.0 - + - 10.0 + 7.5 - + - 12.5 + 10.0 - + - 15.0 + 12.5 - + - 17.5 + 15.0 - + - 20.0 + 17.5 - + + + + + + + + 20.0 + + + Throughput (kFPS) - + - + - + 3D - + - + - + XYView @@ -480,11 +490,11 @@ L 397.33875 295.393233 +" clip-path="url(#pdde0a735f9)" style="fill: #4051b5"/> - - - Throughput (sequential frames, single thread) +" clip-path="url(#pdde0a735f9)" style="fill: #2fb170"/> - 11.1 kFPS + Throughput (sequential frames, single thread) - 17.4 kFPS + 10.9 kFPS + + + 18.2 kFPS @@ -514,118 +524,98 @@ z " style="fill: #ffffff"/> - - - - - - - - 0 - - - + - 10 + 0 - + - 20 + 20 - + - 30 + 40 - + - 40 + 60 - + - 50 + 80 - + - 60 + 100 - + - 70 + 120 - - - - - - - - 80 - - - + Throughput (kFPS) - + - + - + 3D - + - + - + XYView @@ -642,11 +632,11 @@ L 397.33875 398.469083 +" clip-path="url(#pbd9577dced)" style="fill: #4051b5"/> +" clip-path="url(#pbd9577dced)" style="fill: #2fb170"/> - + Throughput (sequential frames, 8 threads) - - 52.9 kFPS + + 73.5 kFPS - - 71.5 kFPS + + 101 kFPS @@ -676,148 +666,148 @@ z " style="fill: #ffffff"/> + + + + + + + + 0.000 + + - + - 0.000 + 0.025 - + - 0.025 + 0.050 - + - 0.050 + 0.075 - + - 0.075 + 0.100 - + - 0.100 + 0.125 - + - 0.125 + 0.150 - + - 0.150 + 0.175 - + - 0.175 - - - - - - - - - 0.200 - + 3D distance from solved pose to target (model units) - + - + - + 0 - + - + - + 25 - + - + - + 50 - + - + - + 75 - + density - - - + + - - - + + @@ -2447,7 +2437,7 @@ L 47.41875 469.731398 L 397.33875 565.172 " style="fill: none; stroke: #000000; stroke-width: 0.8; stroke-linejoin: miter; stroke-linecap: square"/> - + +" clip-path="url(#p3b18a400d2)" style="fill: none; stroke: #4051b5; stroke-width: 2; stroke-linecap: square"/> - + +" clip-path="url(#p3b18a400d2)" style="fill: none; stroke: #2fb170; stroke-width: 2; stroke-linecap: square"/> - + Fit-residual distribution (per-frame average, n=22 real mocap frames) - + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -2632,7 +2622,7 @@ L 127.093047 15.657324 z " style="fill: #4051b5"/> - + 3D @@ -2643,25 +2633,25 @@ L 194.163828 15.657324 z " style="fill: #2fb170"/> - + XYView - + - + - + - + - + diff --git a/benchmark/plot/results/comparison.svg b/benchmark/plot/results/comparison.svg index 7f5a6f1..565591a 100644 --- a/benchmark/plot/results/comparison.svg +++ b/benchmark/plot/results/comparison.svg @@ -1,12 +1,12 @@ - + - 2026-07-24T22:37:41.528356 + 2026-07-27T01:01:02.723375 image/svg+xml @@ -22,8 +22,8 @@ @@ -43,161 +43,181 @@ L 520.9925 135.780962 L 520.9925 56.78 L 100.5125 56.78 z -" clip-path="url(#p99f3fb6f61)" style="fill: #ffec3d; opacity: 0.3; stroke: #ffec3d; stroke-linejoin: miter"/> +" clip-path="url(#p367144b49b)" style="fill: #ffec3d; opacity: 0.3; stroke: #ffec3d; stroke-linejoin: miter"/> - - + - 0.0 + 0 - + - 0.5 + 1 - + - 1.0 + 2 - + - 1.5 + 3 - + - 2.0 + 4 - + - 2.5 + 5 - + + + + + + + + 6 + + + + + + + + + + 7 + + + Latency (ms) - + - - + - + KDL - + - + - + Pinocchio (Python) - + - + - + Pinocchio (C++) - + - + - + RBDL (Python) - + - + - + RBDL (C++) - + - + - + QuickIK (Python) - + - + - + QuickIK (C++) - + - + - + QuickIK (Rust) @@ -214,266 +234,186 @@ L 520.9925 267.449231 +" clip-path="url(#p367144b49b)" style="fill: #4051b5; opacity: 0.5"/> +" clip-path="url(#p367144b49b)" style="fill: #4051b5; opacity: 0.5"/> +" clip-path="url(#p367144b49b)" style="fill: #4051b5; opacity: 0.5"/> +" clip-path="url(#p367144b49b)" style="fill: #4051b5; opacity: 0.5"/> +" clip-path="url(#p367144b49b)" style="fill: #4051b5; opacity: 0.5"/> +" clip-path="url(#p367144b49b)" style="fill: #4051b5"/> +" clip-path="url(#p367144b49b)" style="fill: #4051b5"/> +" clip-path="url(#p367144b49b)" style="fill: #4051b5"/> - - - - - - - - - - - - - - - +" clip-path="url(#p367144b49b)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p367144b49b)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p367144b49b)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p367144b49b)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p367144b49b)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p367144b49b)" style="fill: #2fb170"/> - + +" clip-path="url(#p367144b49b)" style="fill: #2fb170"/> - + - - - - - - - - - - - - - - - - - - Mean single-frame latency (with early stopping) - - - 6.16 ms (capped in chart) +" clip-path="url(#p367144b49b)" style="fill: #2fb170"/> - 1.16 ms + Mean single-frame latency (with early stopping) - 711 μs + 6.20 ms - 322 μs + 1.14 ms - 311 μs + 714 μs - 62.8 μs + 322 μs - 59.8 μs + 312 μs - 63.4 μs + 63.6 μs - 50.2 ms (capped in chart) + 61.1 μs - 887 μs + 66.8 μs - 380 μs + 6.55 ms - 235 μs + 867 μs - 230 μs + 381 μs - 42.7 μs + 235 μs - 41.8 μs + 234 μs - 43.4 μs + 45.0 μs + + + 42.2 μs + + + 44.5 μs - + - + +" clip-path="url(#pc3aa72ed61)" style="fill: #ffec3d; opacity: 0.3; stroke: #ffec3d; stroke-linejoin: miter"/> - - - - - - - - 0 - - - - - - - - - - 2 - - - + - 4 + 0 - + - 6 + 2 - + - 8 + 4 - + - 10 + 6 + + + + + + + + + + 8 + + + + + + + + + + 10 - + Latency (ms) - + - + - + KDL - + - + - + Pinocchio (Python) - + - + - + Pinocchio (C++) - + - + - + RBDL (Python) - + - + - + RBDL (C++) - + - + - + QuickIK (Python) - + - + - + QuickIK (C++) - + - + - + QuickIK (Rust) - + - + - + +" clip-path="url(#pc3aa72ed61)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #4051b5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #4051b5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #4051b5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #2fb170"/> - + +" clip-path="url(#pc3aa72ed61)" style="fill: #2fb170"/> - + - - - Single-frame latency (fixed 10 iterations) - - - 10.3 ms +" clip-path="url(#pc3aa72ed61)" style="fill: #2fb170"/> - 2.23 ms + Single-frame latency (fixed 10 iterations) - 1.42 ms + 10.4 ms - 616 μs + 2.20 ms - 597 μs + 1.42 ms - 124 μs + 614 μs - 119 μs + 601 μs - 128 μs + 126 μs - 6.52 ms + 121 μs - 2.12 ms + 133 μs - 950 μs + 6.57 ms - 393 μs + 2.06 ms - 385 μs + 951 μs - 105 μs + 393 μs - 103 μs + 391 μs - 107 μs + 114 μs + + + 105 μs + + + 111 μs - + - + +" clip-path="url(#p113513ce56)" style="fill: #ffec3d; opacity: 0.3; stroke: #ffec3d; stroke-linejoin: miter"/> - - - - - - - - 0 - - - - - - - - - - 2 - - - + - 4 + 0 - + - 6 + 2 - + - 8 + 4 - + - 10 + 6 - + - 12 + 8 - + - 14 + 10 + + + + + + + + + + 12 - + Throughput (kFPS) - + - + - + KDL - + - + - + Pinocchio (Python) - + - + - + Pinocchio (C++) - + - + - + RBDL (Python) - + - + - + RBDL (C++) - + - + - + QuickIK (Python) - + - + - + QuickIK (C++) - + - + - + QuickIK (Rust) - + - + - + +" clip-path="url(#p113513ce56)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #4051b5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #4051b5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #4051b5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p113513ce56)" style="fill: #2fb170"/> - + +" clip-path="url(#p113513ce56)" style="fill: #2fb170"/> - + - - - Throughput (sequential frames, single thread) +" clip-path="url(#p113513ce56)" style="fill: #2fb170"/> - 125 FPS + Throughput (sequential frames, single thread) - 633 FPS + 124 FPS - 1.03 kFPS + 643 FPS - 2.30 kFPS + 1.03 kFPS - 2.35 kFPS + 2.28 kFPS - 12.1 kFPS + 2.34 kFPS - 12.3 kFPS + 11.7 kFPS - 11.2 kFPS + 11.9 kFPS - 15.3 FPS + 10.9 kFPS - 454 FPS + 15.2 FPS - 1.05 kFPS + 464 FPS - 2.52 kFPS + 1.05 kFPS - 2.54 kFPS + 2.53 kFPS - 9.46 kFPS + 2.53 kFPS - 9.61 kFPS + 9.14 kFPS - 9.19 kFPS + 9.15 kFPS + + + 7.95 kFPS - + - + +" clip-path="url(#p43a69c3c0e)" style="fill: #ffec3d; opacity: 0.3; stroke: #ffec3d; stroke-linejoin: miter"/> - - - - - - - - 0 - - - + - 10 + 0 - + - 20 + 10 - + - 30 + 20 - + - 40 + 30 - + - 50 + 40 - + - 60 + 50 - + - 70 + 60 - + - 80 + 70 + + + + + + + + + + 80 - + Throughput (kFPS) - + - + - + KDL - + - + - + Pinocchio (Python) - + - + - + Pinocchio (C++) - + - + - + RBDL (Python) - + - + - + RBDL (C++) - + - + - + QuickIK (Python) - + - + - + QuickIK (C++) - + - + - + QuickIK (Rust) - + - + - + +" clip-path="url(#p43a69c3c0e)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #4051b5; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #4051b5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #4051b5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #4051b5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #2fb170; opacity: 0.5"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #2fb170"/> - + +" clip-path="url(#p43a69c3c0e)" style="fill: #2fb170"/> - + - - - Throughput (sequential frames, 8 threads) +" clip-path="url(#p43a69c3c0e)" style="fill: #2fb170"/> - 892 FPS + Throughput (sequential frames, 8 threads) - 4.42 kFPS + 886 FPS - 7.47 kFPS + 4.57 kFPS - 15.8 kFPS + 7.46 kFPS - 17.1 kFPS + 14.9 kFPS - 77.6 kFPS + 17.1 kFPS - 74.1 kFPS + 76.1 kFPS - 75.4 kFPS + 77.4 kFPS - 110 FPS + 73.5 kFPS - 3.22 kFPS + 109 FPS - 7.67 kFPS + 3.12 kFPS - 16.5 kFPS + 7.63 kFPS - 18.8 kFPS + 15.5 kFPS - 58.4 kFPS + 18.8 kFPS - 61.4 kFPS + 59.4 kFPS - 61.9 kFPS + 62.8 kFPS + + + 59.0 kFPS - + - + NeuroMechFly (42 DOFs) - + - + G1 humanoid (29 DOFs) - + - + - + - + diff --git a/benchmark/plot/results/scaling.svg b/benchmark/plot/results/scaling.svg index 57e2329..83962b8 100644 --- a/benchmark/plot/results/scaling.svg +++ b/benchmark/plot/results/scaling.svg @@ -6,7 +6,7 @@ - 2026-07-24T22:37:42.207830 + 2026-07-27T01:01:03.415472 image/svg+xml @@ -41,12 +41,12 @@ z - - + @@ -56,7 +56,7 @@ L 0 3.5 - + @@ -66,7 +66,7 @@ L 0 3.5 - + @@ -76,7 +76,7 @@ L 0 3.5 - + @@ -86,7 +86,7 @@ L 0 3.5 - + @@ -101,12 +101,12 @@ L 0 3.5 - - + @@ -116,7 +116,7 @@ L -3.5 0 - + @@ -126,7 +126,7 @@ L -3.5 0 - + @@ -136,7 +136,7 @@ L -3.5 0 - + @@ -146,7 +146,7 @@ L -3.5 0 - + @@ -163,17 +163,17 @@ L 147.225385 168.113266 L 226.916298 123.711049 L 306.607211 79.308832 L 386.298124 34.906615 -" clip-path="url(#pa2a2838ea2)" style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #808080; stroke-width: 1.5"/> +" clip-path="url(#p2a7bf0b2c6)" style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #808080; stroke-width: 1.5"/> +L 147.225385 174.427266 +L 226.916298 133.06074 +L 306.607211 92.425591 +L 386.298124 84.641694 +" clip-path="url(#p2a7bf0b2c6)" style="fill: none; stroke: #4051b5; stroke-width: 1.5; stroke-linecap: square"/> - - - - - - - + + + + + + @@ -207,16 +207,16 @@ L 402.236307 235.363672 1.00x - 1.88x + 1.81x - 3.58x + 3.46x - 6.91x + 6.52x - 7.82x + 7.36x Speedup v.s. workers @@ -237,7 +237,7 @@ L 70.596289 60.832813 L 80.596289 60.832813 " style="fill: none; stroke: #4051b5; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -247,7 +247,7 @@ L 80.596289 60.832813 - + diff --git a/benchmark/preprocessing/g1_body_plan.py b/benchmark/preprocessing/g1_body_plan.py index 165abd8..4a8df69 100644 --- a/benchmark/preprocessing/g1_body_plan.py +++ b/benchmark/preprocessing/g1_body_plan.py @@ -2,7 +2,7 @@ schema as `benchmark/assets/neuromechfly_ypr_legs.json` -- see `benchmark/scripts/generate_fixtures.py`'s docstring and `src/body_plan.rs`): - {"metadata": {"name": ...}, + {"fixed_base": bool, "x-name": ..., "joints": [{"name", "parent", "offset_pos": [x,y,z], "offset_quat": [w,x,y,z], "dofs": [...]}]} @@ -90,7 +90,7 @@ ROOT_LINK = "pelvis" # Each DOF's `neutral` value -- the solver's regularization anchor -# (`SolverConfig::weight`) and `State::neutral_pose`'s starting guess. The +# (`SolverConfig::neutral_weight`) and `State::neutral_pose`'s starting guess. The # URDF's own zero configuration (no retargeted motion data feeds this # anymore -- see g1_fixtures.py's module docstring for why). NEUTRAL_ANGLES = [0.0] * len(REVOLUTE_JOINT_ORDER) @@ -209,11 +209,11 @@ def build_g1_body_plan(neutral_angles): } ) - return {"metadata": {"name": "g1_29dof"}, "joints": nodes} + return {"fixed_base": False, "x-name": "g1_29dof", "joints": nodes} if __name__ == "__main__": body_plan = build_g1_body_plan(NEUTRAL_ANGLES) - OUT_PATH.write_text(json.dumps(body_plan, indent=2)) + OUT_PATH.write_text(json.dumps(body_plan, indent=2) + "\n") n_dofs = sum(len(j["dofs"]) for j in body_plan["joints"]) print(f"Wrote {len(body_plan['joints'])} joints ({n_dofs} DOFs) to {OUT_PATH}") diff --git a/benchmark/quickik_cpp/bench_cpp.cpp b/benchmark/quickik_cpp/bench_cpp.cpp index 4aad64a..91dd50f 100644 --- a/benchmark/quickik_cpp/bench_cpp.cpp +++ b/benchmark/quickik_cpp/bench_cpp.cpp @@ -97,7 +97,7 @@ void run_correctness(const BodyPlan &plan, const Json &fixtures, rust::Box6} {'kpt rms':>16} {'kpt max':>16} {'angle err deg':>18} {'angle err deg (w=0)':>20}" ) default_solver = quickik.Solver(tree, quickik.SolverConfig()) - zero_reg_solver = quickik.Solver(tree, quickik.SolverConfig(weight=0.0)) + zero_reg_solver = quickik.Solver(tree, quickik.SolverConfig(neutral_weight=0.0)) for i, frame in enumerate(ctx.fixtures["synthetic_frames"]): target = np.array(frame["target_ego"]) ground_truth = np.concatenate( diff --git a/benchmark/quickik_rust/src/correctness.rs b/benchmark/quickik_rust/src/correctness.rs index 7deeae4..c6995ab 100644 --- a/benchmark/quickik_rust/src/correctness.rs +++ b/benchmark/quickik_rust/src/correctness.rs @@ -71,7 +71,7 @@ pub fn run_synthetic_frame_tests(tree: &Arc, frames: &[SyntheticF let mut zero_reg_solver: Solver = Solver::new( tree, SolverConfig { - weight: 0.0, + neutral_weight: 0.0, ..SolverConfig::default() }, ); @@ -194,7 +194,7 @@ pub fn run_synthetic_frame_tests_2d( let mut zero_reg_solver: Solver = Solver::new( tree, SolverConfig { - weight: 0.0, + neutral_weight: 0.0, mapper: Some(mapper), ..SolverConfig::default() }, diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 8d2c790..f9d8ca7 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -35,9 +35,16 @@ add_custom_target( # Two static libs come out of the Rust side: the crate's own compiled code # (libquickik_cpp.a) and the cxx-bridge-generated C++ glue (compiled by -# cxx_build/cc, libquickik-cpp-bridge.a). They reference each other's symbols -# (Rust calls into cxx's C++ runtime helpers; C++ calls the exported -# `extern "Rust"` functions), so both must be linked together. +# cxx_build/cc, libquickik-cpp-bridge.a). Listing both below looks like it +# should need circular-dependency handling (Rust calls into cxx's C++ +# runtime helpers; C++ calls the exported `extern "Rust"` functions) -- but +# quickik_cpp is a Rust `staticlib`, and rustc bundles libquickik-cpp-bridge.a's +# object files directly into libquickik_cpp.a's own archive at build time. +# So the two archives' contents actually overlap: whichever is scanned first +# during linking satisfies every symbol, and the identical duplicate content +# in the other is simply never extracted. That means plain archive linking +# (no `--start-group`/`--end-group`, a GNU ld feature Apple's linker doesn't +# understand at all) works the same on every platform. add_library(quickik_cpp STATIC IMPORTED GLOBAL) set_target_properties(quickik_cpp PROPERTIES IMPORTED_LOCATION ${QUICKIK_TARGET_DIR}/libquickik_cpp.a) add_dependencies(quickik_cpp quickik_cargo_build) @@ -46,12 +53,14 @@ add_library(quickik_cpp_bridge STATIC IMPORTED GLOBAL) set_target_properties(quickik_cpp_bridge PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/lib/libquickik-cpp-bridge.a) add_dependencies(quickik_cpp_bridge quickik_cargo_build) +set(QUICKIK_RUST_LINK_LIBS quickik_cpp_bridge quickik_cpp) + add_executable(quickik_cpp_tests tests/test_main.cpp) target_include_directories(quickik_cpp_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) add_dependencies(quickik_cpp_tests quickik_cargo_build) target_link_libraries( quickik_cpp_tests PRIVATE - -Wl,--start-group quickik_cpp_bridge quickik_cpp -Wl,--end-group + ${QUICKIK_RUST_LINK_LIBS} pthread dl m ) @@ -64,6 +73,6 @@ target_compile_options(quickik_cpp_benchmark PRIVATE -O3) add_dependencies(quickik_cpp_benchmark quickik_cargo_build) target_link_libraries( quickik_cpp_benchmark PRIVATE - -Wl,--start-group quickik_cpp_bridge quickik_cpp -Wl,--end-group + ${QUICKIK_RUST_LINK_LIBS} pthread dl m ) diff --git a/cpp/src/lib.rs b/cpp/src/lib.rs index 6b527e7..cd68873 100644 --- a/cpp/src/lib.rs +++ b/cpp/src/lib.rs @@ -80,10 +80,10 @@ mod ffi { #[derive(Clone, Copy, Debug)] struct SolverConfig { n_iterations: usize, - damping: f32, - weight: f32, + neutral_weight: f32, position_tolerance: f32, angle_tolerance: f32, + damping: f32, } /// Configuration for `solve_sequence_segmented_parallel`. @@ -122,6 +122,13 @@ mod ffi { /// damping and neutral-pose weight, and tolerances of 1e-3. fn default_solver_config() -> SolverConfig; + /// A `ParallelSolveConfig` that spreads `total_len` frames evenly + /// across every available core: one segment per core, `total_len / + /// n_workers` frames each (plus a fixed default overlap). For finer + /// control over cold-start frequency, build a `ParallelSolveConfig` + /// directly instead. + fn parallel_solve_config_for_recording(total_len: usize) -> ParallelSolveConfig; + /// A kinematic tree, i.e. body plan, or skeleton. type KinematicTree; /// Parses a `KinematicTree` from a JSON body-plan string. @@ -403,10 +410,10 @@ fn to_core_config( ) -> quickik_core::solver::SolverConfig { quickik_core::solver::SolverConfig { n_iterations: config.n_iterations, - damping: config.damping, - weight: config.weight, + neutral_weight: config.neutral_weight, position_tolerance: config.position_tolerance, angle_tolerance: config.angle_tolerance, + damping: config.damping, mapper, } } @@ -416,10 +423,10 @@ fn from_core_config( ) -> ffi::SolverConfig { ffi::SolverConfig { n_iterations: config.n_iterations, - damping: config.damping, - weight: config.weight, + neutral_weight: config.neutral_weight, position_tolerance: config.position_tolerance, angle_tolerance: config.angle_tolerance, + damping: config.damping, } } @@ -626,6 +633,20 @@ impl SequenceSolver { } } +// ============================================================================= +// ParallelSolveConfig +// ============================================================================= + +fn parallel_solve_config_for_recording(total_len: usize) -> ffi::ParallelSolveConfig { + let config = quickik_core::high_level::ParallelSolveConfig::for_recording(total_len); + ffi::ParallelSolveConfig { + segment_len: config.segment_len, + overlap_len: config.overlap_len, + overlap_tolerance: config.overlap_tolerance, + n_workers: config.n_workers, + } +} + // ============================================================================= // solve_sequence_segmented_parallel // ============================================================================= diff --git a/cpp/tests/test_main.cpp b/cpp/tests/test_main.cpp index a0b42b9..cda9c0a 100644 --- a/cpp/tests/test_main.cpp +++ b/cpp/tests/test_main.cpp @@ -66,7 +66,7 @@ rust::Slice slice_of(const std::vector> true_angles; + std::vector flat; + for (size_t t = 0; t < n_frames; t++) { + float a = 0.3f * std::sin(t * 0.15f); + true_angles.push_back({a, a * 0.5f}); + for (auto &obs : observations_for(a, a * 0.5f)) flat.push_back(obs); + } + + auto parallel_config = quickik::parallel_solve_config_for_recording(n_frames); + auto states = quickik::solve_sequence_segmented_parallel( + *tree, quickik::default_solver_config(), slice_of(flat), tree->n_joints(), parallel_config, quickik::no_mapper()); + +#define CHECK(cond) \ + if (!(cond)) { std::fprintf(stderr, " FAILED: %s (line %d)\n", #cond, __LINE__); ok = false; } + CHECK(states->len() == n_frames); + for (size_t i = 0; i < n_frames; i++) { + auto state = states->at(i); + auto angles = state->dof_angles(); + CHECK(std::abs(angles[0] - true_angles[i][0]) < 1e-2f); + CHECK(std::abs(angles[1] - true_angles[i][1]) < 1e-2f); + } +#undef CHECK + return ok; +} + bool test_solve_sequence_segmented_parallel_honors_explicit_n_workers() { bool ok = true; auto tree = two_joint_chain(); @@ -420,6 +449,8 @@ int main() { {"solve_sequence_returns_one_state_per_frame", test_solve_sequence_returns_one_state_per_frame}, {"solve_sequence_segmented_parallel_reconstructs_smooth_trajectory", test_solve_sequence_segmented_parallel_reconstructs_smooth_trajectory}, + {"parallel_solve_config_for_recording_reconstructs_smooth_trajectory", + test_parallel_solve_config_for_recording_reconstructs_smooth_trajectory}, {"solve_sequence_segmented_parallel_honors_explicit_n_workers", test_solve_sequence_segmented_parallel_honors_explicit_n_workers}, }; diff --git a/devtools-pyenv/pyproject.toml b/devtools-pyenv/pyproject.toml index 55eb4b1..1e0e5c4 100644 --- a/devtools-pyenv/pyproject.toml +++ b/devtools-pyenv/pyproject.toml @@ -8,6 +8,17 @@ dependencies = [ "matplotlib>=3.11.1", "numpy>=2.5.1", "pytest>=9.1.1", + "quickik", "ruff>=0.15.22", "scipy>=1.18.0", + "zensical>=0.0.51", ] + +[tool.uv.sources] +# Path dependency so `uv sync` keeps quickik installed (it would otherwise be +# stripped on every sync, since nothing else here declares it). This only +# guarantees quickik *stays* installed -- `uv sync` alone won't rebuild it +# after a Rust source change (../python/src, ../src, ../cpp/src): rerun +# `uv sync --reinstall-package quickik` (or `cd ../python && maturin develop +# --release` directly) whenever those change. +quickik = { path = "../python", editable = true } diff --git a/devtools-pyenv/uv.lock b/devtools-pyenv/uv.lock index f048799..47df174 100644 --- a/devtools-pyenv/uv.lock +++ b/devtools-pyenv/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -86,6 +98,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "deepmerge" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/78/6e9e20106224083cfb817d2d3c26e80e72258d617b616721a169b87081e0/deepmerge-2.1.0.tar.gz", hash = "sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f", size = 21449, upload-time = "2026-06-22T05:46:07.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" }, +] + [[package]] name = "devtools-pyenv" version = "0.1.0" @@ -95,8 +116,10 @@ dependencies = [ { name = "maturin" }, { name = "numpy" }, { name = "pytest" }, + { name = "quickik" }, { name = "ruff" }, { name = "scipy" }, + { name = "zensical" }, ] [package.metadata] @@ -105,8 +128,10 @@ requires-dist = [ { name = "maturin", specifier = ">=1.7,<2.0" }, { name = "numpy", specifier = ">=2.5.1" }, { name = "pytest", specifier = ">=9.1.1" }, + { name = "quickik", editable = "../python" }, { name = "ruff", specifier = ">=0.15.22" }, { name = "scipy", specifier = ">=1.18.0" }, + { name = "zensical", specifier = ">=0.0.51" }, ] [[package]] @@ -159,6 +184,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -245,6 +282,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "matplotlib" version = "3.11.1" @@ -469,6 +578,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -506,6 +628,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "quickik" +version = "0.1.0" +source = { editable = "../python" } +dependencies = [ + { name = "numpy" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy" }] + [[package]] name = "ruff" version = "0.15.22" @@ -590,3 +769,78 @@ sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68 wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "zensical" +version = "0.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/f7/d07ffb268ca86afb26b7f32dbabe25dec03d3aa63ba4d876720c84681d33/zensical-0.0.51.tar.gz", hash = "sha256:de25de067bedfa18f916d7f366fd64a7fbf09bfcc615b44d1ddbe3b5fe02ab49", size = 3979640, upload-time = "2026-07-17T18:08:03.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/21/02db3e1fb3904016bfac310037c95b9f1eaaf0ffe7b4a84f14263a7d95df/zensical-0.0.51-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:134d776afa526098e05e34713e2f577c075e57a232e01b97842bb0206716afce", size = 12791154, upload-time = "2026-07-17T18:07:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/35/b0d96f58253514cb3d08f5779020ab01ee5472334fb984b92e3fc9e9c9ac/zensical-0.0.51-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e97ab39668ae3b452c550634e921a0336443743aae5e1fe031c7bb57d049e535", size = 12692190, upload-time = "2026-07-17T18:07:24.553Z" }, + { url = "https://files.pythonhosted.org/packages/2e/90/7a60e126a10c37c6b789938ff17e73fe76bba707fa029cb40ac659aeaa82/zensical-0.0.51-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c9579809f88608e7aa2cff516fff9d267d74a843cf6088a5f4227de2f092bb5", size = 13139337, upload-time = "2026-07-17T18:07:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c3/9101c97b90d4713ef2816db03366a45ae4762efebffd296737a2dd2df325/zensical-0.0.51-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296dc7a14aa28b81a58eb57df2d5c9c9a4b0de7e90c11d99c943354287952925", size = 13069851, upload-time = "2026-07-17T18:07:31.814Z" }, + { url = "https://files.pythonhosted.org/packages/d2/79/0474df9e15a2c18f6281a786e10177c1b6e16feac1c568e7f36ad39b339c/zensical-0.0.51-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f779d2d87b4bf228cf2e279bc0ae6bcf3b36a9335ff283a317d01f7c15ae46b2", size = 13451083, upload-time = "2026-07-17T18:07:35.543Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/91bbf78f704d5fd4c0c9be27d6bce3b6e4c2c339e4dcd6e7cf19ecda643c/zensical-0.0.51-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f813a1514a90890ca86248a8d54b81b2164bcbff11a6bcf11b01e1c01a1454", size = 13110446, upload-time = "2026-07-17T18:07:38.783Z" }, + { url = "https://files.pythonhosted.org/packages/d9/89/aa9a95f81771614c37bdc52b8ab21fcdef4c8de7c9cedf34e9bf62674281/zensical-0.0.51-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:186ef37e0eee0e969e2cfae47b1b97775e3164e2cba95c71faa4dd6ef47ed009", size = 13315871, upload-time = "2026-07-17T18:07:42.43Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/1bf6e9ded29d376f8c12644cc4de04676b010fee8caa17f682606b1f16d5/zensical-0.0.51-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5d91ce246ed930224603083cef02ae8947132fc7c52901d72015ea03526fa58", size = 13344382, upload-time = "2026-07-17T18:07:46.066Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/663f16ff82d08b212e7c3236a88bd332f73331f94ddac1c91aaf882bbd1e/zensical-0.0.51-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1108eae82c6e8ffc33026f60b485c1512647a5333be4f547166b7c8877b98af", size = 13499628, upload-time = "2026-07-17T18:07:49.196Z" }, + { url = "https://files.pythonhosted.org/packages/60/b4/7f1b6c3cf06d9f6ff5216523168a5d6ccc693444d5ceeb911eca97b30d98/zensical-0.0.51-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0ecaf14f56841bfc595fa141396350c72aafbec73a016ebe3c824ed21ac72", size = 13451420, upload-time = "2026-07-17T18:07:52.563Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/be4bc09ec8f69e7be1b07b875887961c4e0e478b03a10d2cc624ef28fbe6/zensical-0.0.51-cp310-abi3-win32.whl", hash = "sha256:fb7ff4946b72168759c6af0a29cf5de4c38aebe633a83292e8cd4145b5213cc2", size = 12375639, upload-time = "2026-07-17T18:07:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/1f/85/aa827c244ed4f404e99a91c3ecf5e5adb62eca806a9e9c8e3333bbad8660/zensical-0.0.51-cp310-abi3-win_amd64.whl", hash = "sha256:12529d3d3991b63820952111dc1d1edc29b2c9b3a3abb16c243bcb649631ebf2", size = 12628965, upload-time = "2026-07-17T18:07:59.741Z" }, +] diff --git a/docs/benchmarks.md b/docs/benchmarks.md deleted file mode 100644 index 898d7d7..0000000 --- a/docs/benchmarks.md +++ /dev/null @@ -1,37 +0,0 @@ -# Benchmarks - -The following results are obtained usings an 8-core (16-thread) Intel® Core™ i9-11900K processor. See [`benchmark/README.md`](https://github.com/NeLy-EPFL/quickik/blob/main/benchmark/README.md) to reproduce these numbers. - -## Comparison with other libraries - -QuickIK is compared against three other whole-tree IK libraries – [KDL](https://github.com/orocos/orocos_kinematics_dynamics), [Pinocchio](https://github.com/stack-of-tasks/pinocchio), and [RBDL](https://github.com/rbdl/rbdl) – on the same task, across QuickIK's Rust API, Python bindings, and C++ bindings. Two inverse kinematics tasks are benchmarked: [**NeuroMechFly**](https://neuromechfly.org/) (biomechanical model of a fruit fly with 42 leg DOFs with real behavior recording data), and **G1** (a Unitree humanoid with 29 DOFs, driven by raw human walking data from the [LAFAN1](https://github.com/ubisoft/ubisoft-laforge-animation-dataset) dataset, rescaled onto G1's proportions – retargeting a human skeleton onto a robot is itself an IK problem, so each compared solver does that retarget too, rather than starting from a pre-retargeted dataset; expect a real, nonzero fit residual, same as the fly's own mocap). - -??? note "Other inverse kinematics libraries and why they are not included" - Those libraries solve a categorically easier problem: a single fixed-base kinematic chain reaching one end-effector target, not a free-floating base fit jointly against many keypoints across multiple limbs. There's no way to run them "the same task, just slower" without either bolting the base down and solving one limb at a time (losing the floating base and the joint multi-limb fit) or writing a different, non-standard algorithm. Benchmarking them would measure a different, strictly smaller problem, flattering every excluded library rather than QuickIK. KDL, Pinocchio, and RBDL are included because each one can fit an arbitrary floating base against multiple simultaneous keypoints. - -!!! info "Definition of metrics" - - **Latency**: how long one cold-start `solve()` call takes against a fixed target. Reported both as the *mean* under each library's own early-stopping behavior, and as a *worst case* with early stopping disabled, so every solve runs the same fixed number of iterations regardless of how easy the target is. - - **Throughput (single thread)**: frames per second solving a long recording one frame at a time, each warm-started from the previous frame's solution. - - **Throughput (multi-thread, 8 threads)**: the same recording split across 8 threads and solved in parallel, for libraries/bindings that support it. - -![Benchmark comparison across both the NeuroMechFly fly body and the Unitree G1 humanoid body](assets/benchmarks/comparison.svg){ width="480" } - - -??? Note "Implementation notes" - === "QuickIK (Python/C++/Rust)" - All three of QuickIK's own bindings run the identical compiled Rust solver (the core `quickik` crate, same release build) – the Python and C++ layers are thin FFI wrappers around it, not separate implementations. Python's throughput metrics batch a whole recording into one call via a numpy-array API (flat position/weight arrays) instead of one Python object per keypoint per frame, which is what keeps it within a few percent of Rust and C++ here; an earlier, naive per-frame-object-list version of the same binding was roughly 2x slower on these metrics, since Python/PyO3 call and object-marshaling overhead then dominated the actual (fast) per-frame solve. Any remaining C++-vs-Rust gap in the chart is measurement noise from CPU scheduling on a shared machine (see `benchmark/README.md`'s "Reducing measurement noise" section), not a real difference, since both run the same code. - - === "RBDL" - RBDL's native floating-base joint crashes its `InverseKinematicsConstraintSet` solver (an upstream dimension-mismatch bug), so QuickIK's floating base is represented as a translation plus Euler-angle joint in series instead, matching KDL's workaround for the same underlying reason. Its solver is a joint-space damped Levenberg-Marquardt normal-equations solve, tuned to match QuickIK's own iteration count and tolerance for a fair comparison. Python bindings wrap the same native C++ solver (via Cython), so Python and C++ perform almost identically on latency and single-thread throughput; multi-thread throughput uses `multiprocessing` in Python versus in-process threads in C++, which accounts for the modest gap there. - - === "Pinocchio" - Pinocchio provides forward kinematics, Jacobians, and configuration integration as building blocks, not a ready-made multi-keypoint solve() call – its own official IK tutorials are a hand-written Newton loop on top of those primitives (turnkey IK solvers like TSID are built on top of Pinocchio, not part of it). This benchmark's Gauss-Newton/Levenberg-Marquardt loop is hand-written the same way, matching QuickIK's solver as closely as possible (same residual formulation, damping, tolerance, and iteration cap). The Python benchmark pays Python-level overhead on every solver iteration (unlike RBDL's single-call-per-frame Cython wrapper), which is why a native C++ port exists and measures meaningfully faster – though still slower than RBDL's C++ numbers, mostly due to Pinocchio's per-keypoint Jacobian extraction API. - - === "KDL" - KDL has no native 6-DOF floating joint or position-only solver, so QuickIK's floating base is represented as 6 scalar joints in series, and the 3 rotational rows of every endpoint's task-space weight matrix are zeroed so orientation error never drives the solve. KDL remains the slowest solver here even once its early-stopping is fixed to match QuickIK's own tolerance: its `TreeIkSolverVel_wdls` computes a dense SVD of the full weighted task-space Jacobian every iteration, where RBDL and QuickIK instead form and solve the much cheaper normal-equations matrix – an algorithmic difference between the libraries' building blocks, not a stopping-criterion artifact. - -## Scaling - -A separate [weak-scaling test](https://hpc-wiki.info/hpc/Scaling#Weak_Scaling) (QuickIK's Rust API, using NeuroMechFly) measures how throughput grows as both thread count and total workload grow together. - -![Speedup vs. worker threads](assets/benchmarks/scaling.svg) diff --git a/docs/build.sh b/docs/build.sh index 844a978..3a9c453 100755 --- a/docs/build.sh +++ b/docs/build.sh @@ -4,9 +4,9 @@ # (copied from wherever benchmark/plot/*.py last wrote them) -- then builds # or serves the site. # -# Prerequisites on PATH: cargo, zensical (`uv tool install zensical`), -# doxygen (system package manager). uv itself is used to manage a throwaway -# venv for maturin/pdoc. +# Prerequisites on PATH: cargo, uv, doxygen (system package manager). +# zensical comes from devtools-pyenv (`uv sync` there); uv itself is used +# to manage a throwaway venv for maturin/pdoc. # # Usage: docs/build.sh [build|serve] (default: build) set -euo pipefail @@ -61,4 +61,4 @@ else echo "docs/build.sh: no charts in benchmark/plot/results/ yet -- see benchmark/README.md" >&2 fi -zensical "${1:-build}" +uv run --project devtools-pyenv zensical "${1:-build}" diff --git a/docs/getting-started/2d-keypoints.md b/docs/getting-started/2d-keypoints.md new file mode 100644 index 0000000..1063777 --- /dev/null +++ b/docs/getting-started/2d-keypoints.md @@ -0,0 +1,58 @@ +# From 2D keypoint positions + +Not every tracking source gives 3D positions directly: a single camera view only gives 2D pixel coordinates, and recovering the underlying 3D pose is itself part of what the solve needs to do. `Position2D` observations cover this: + +- **`Camera`:** a pinhole projection model (focal lengths, principal point, and the camera's own pose relative to the body plan's world frame). QuickIK projects each candidate 3D keypoint position through it before comparing to the observed pixel coordinates, rather than comparing 3D positions directly. +- **`XYView`:** keypoints already reprojected onto a physical X-Y plane (e.g., an overhead tracking setup). No camera intrinsics/extrinsics involved, just the identity projection dropping Z. + +Set `SolverConfig`'s mapper to either one to switch a solver from 3D to 2D observations: + +=== "Rust" + + ```rust + use quickik::observation::XYView; + + let ortho_xy = Some(XYView); + let config = SolverConfig { mapper: ortho_xy, ..SolverConfig::default() }; + let mut solver: Solver = Solver::new(&kinematic_tree, config); + ``` + +=== "Python" + + ```python + ortho_xy = quickik.XYView() + solver = quickik.Solver(kinematic_tree, quickik.SolverConfig(), mapper=ortho_xy) + ``` + + !!! note "Handling of `mapper` in Python" + Rust's `Solver` is generic over the mapper type. Python doesn't have an equivalent. Instead, the solver config instance holds every attribute that the Rust `SolverConfig` has except the mapper. The `__init__` method of the `Solver` receives the kinematic tree, the solver config, _plus a mapper_, as arguments. The optional `mapper` argument can be `None` (for 3D keypoints, default), a `Camera` object, or an empty `XYView` object. + +=== "C++" + + ```cpp + auto solver_config = quickik::default_solver_config(); + auto ortho_xy = quickik::xyview_mapper(); + auto solver = quickik::new_solver(*tree, solver_config, ortho_xy); + ``` + + !!! note "Handling of `mapper` in C++" + Rust's `Solver` is generic over the mapper type. C++ doesn't have an equivalent. Instead, the solver config instance holds every attribute that the Rust `SolverConfig` has except the mapper. The `new_solver` function receives the kinematic tree, the solver config, _plus a mapper_, as arguments. The `mapper` argument can be built via `no_mapper()` (for 3D keypoints), `camera_mapper(camera)`, or `xyview_mapper()`. + +!!! warning "Inverse kinematics from 2D keypoint positions is fundamentally underconstrained" + Fitting 3D kinematic states from only 2D keypoints is fundamentally a degenerate, underconstrained problem. QuickIK's 2D capability doesn't magically solve that. The user must take the inverse kinematics from 2D poses with a grain of salt and validate it more rigorously. + + !!! tip + Increasing the weight for the neutral pose prior generally improves the quality of inverse kinematics from 2D data. Better camera angles also make a huge difference. + + +## 2D vs. 3D fit + +In the following video, the blue lines show the result of inverse kinematics based on 3D keypoint positions, shown as gray dots in 3D. + +On the floor "X-Y projection" pane, the same observed keypoint positions are projected to 2D. QuickIK attempts another inverse kinematics reconstruction, this time given only these 2D observations. From these reconstructed joint angles, albeit based on 2D data, one can nevertheless recover a 3D pose, shown in green. + +Of course, limitations exist: The green fit matches the observations about as well as blue on the X-Y plane (see projections on the floor), but the green keypoints deviate from their ground truth more noticeably in 3D. This comes from a fundamental limitation in data. + + \ No newline at end of file diff --git a/docs/getting-started/body-plan.md b/docs/getting-started/body-plan.md index cd2c058..29eefb7 100644 --- a/docs/getting-started/body-plan.md +++ b/docs/getting-started/body-plan.md @@ -1,61 +1,86 @@ # Body plan -A body plan describes the kinematic tree QuickIK solves against – a robot's joints, or an animal's skeleton – loaded once from JSON and reused across every solve. Unlike a typical robotics kinematic tree, which separates joints (that move) from frames or links (that get tracked), here every joint doubles as a keypoint: its world position is always available as a potential target, whether or not it carries a rotational DOF of its own. This is what lets one uniform `KeypointObservation` list – one entry per joint, in the body plan's own tree order – describe a whole tracked pose, DOF-bearing joints and fixed leaf keypoints alike. +A body plan describes the kinematic tree QuickIK solves against – a robot's joints, or an animal's skeleton. -One modeling consequence worth knowing: a joint's own DOF reorients its *children*, not itself – rotating a joint never moves its own keypoint, only the keypoints downstream of it. So every DOF needs at least one keypoint further down its own chain to be observable at all; a chain that ends exactly at its last DOF-bearing joint, with nothing past it, leaves that DOF's angle undetermined by any observation. This is why even a fixed, 0-DOF "tip" joint (a fingertip, a fly's claw, a robot's end effector) is usually worth keeping in the body plan even though it never actuates anything itself. +In QuickIK, the kinematic tree is defined using **joints**. Each joint can contain multiple **degrees of freedom (DOFs)**. There are two types of DOFs: *hinges* that rotate, and *slides* that move translationally. For example, a 3-axis ball joint would have three hinge DOFs and no slide DOF. -By default the root is a free-floating base with its own 6 DOFs (position and rotation), solved for like everything else – this fits a tracked animal or a robot free to move through the world. Setting the top-level `"fixed_base": true` instead anchors the root in place (e.g. a robot arm bolted to a table), removing those 6 DOFs from the state entirely. A *semi*-fixed base – one that only slides along a rail or spins on a turntable – isn't a separate setting: keep `fixed_base` set and give the root a zero-offset child joint carrying that one hinge/slide DOF, then attach the rest of the body to that joint instead of directly to the root. Because a joint's own DOF only moves its descendants (see above), this joint acts as exactly that one-DOF base – and unlike the root's own DOFs, it gets `limits` and `weight_scaler` like any other DOF. +All joints trace back to a single parent – the **root** of the kinematic tree. In principle, the definition of the root is arbitrary (you can say the whole human body stems from the left index fingertip if you wish), but practically it should be defined as a central body part like the pelvis or thorax. The root can be *freely floating* – useful when the body is a robot or an animal that can move around, or *fixed* – useful for fixed-base robotic arms. -??? note "Body plan JSON schema" - ```json +In QuickIK, joints double as **keypoints**[^1] – the points on the body whose positions are recorded and used to constrain the state. If you need a keypoint in the middle of a body segment, add a "pseudo joint" where they keypoint is supposed to be with no associated DOFs. + +[^1]: + This is merely a practical choice, as articulated joints are usually easier to track than arbitrary points on the body and indeed they are what's typically available in MoCap/pose estimation data. + +**Each joint has the following properties:** + +- A **name**. +- A **parent joint** (the parent of the root joint is `null`). +- A **position offset** and a **rotation offset** from its parent joint, representing the properties of the rigid-body link connecting them. Rotation offsets are specified in quaternions in wxyz format. +- A **weight scaler** that controls the _scale_[^2] of how hard the solver should try to minimize the mismatch of this joint's position. If some joints are intrinsically harder to measure (e.g., if they are usually occluded or embedded in soft tissues), it's useful to lower this number. +- A list of **degrees of freedom** (DOFs). The order of the DOFs is important, as 3D rotations do not commute. + +[^2]: + During inverse kinematics, the user can also supply a weight for each keypoint on a frame-to-frame basis (e.g., using the uncertainty measure of the pose estimation model on that particular frame). The final weight is the product of the weight supplied at runtime and this scaler. + +**Each DOF has the following properties:** + +- A **type**: can be `hinge` or `slide`. +- An **axis**: this is the rotational axis for hinge joints or translational axis for slide joints. +- A **neutral** value: the "natural" rotation angle or slide position the solver favors poses being closer to. For hinge DOFs, this is in radians; for slide DOFs, it's in whatever unit the joint's position offset uses. +- Optionally, the **limits** for the value of this DOF (angle for hinge joints, positions for slide joints). Same unit as the neutral value. Set to `null` if unbounded. +- A **weight scaler** controlling the _scale_[^3] of how strongly the solver favors the neutral state defined above. + +[^3]: + Upon initiating the inverse kinematics solver, the user can define a weight for the pull toward the neutral pose. Like the weight scaler for the joint's weight, the final weight toward the neutral value for each DOF is the product of the weight supplied at runtime and the scaler specified here in the body plan. + + +## JSON body plan format + +In QuickIK, the body plan is specified in JSON. An example JSON file is as follows. + +!!! info "JSON schema" + A formal schema of the JSON format is [available here](https://datasets.epfl.ch/nely-public-share/quickik_assets/docs/bodyplan_20260726.schema.json). You can use it for formal [syntax check with your IDE](https://code.visualstudio.com/docs/languages/json). + +```json +{ + "fixed_base": false, + "joints": [ { - "fixed_base": false, - "joints": [ - { - "name": "root", - "parent": null, - "offset_pos": [0.0, 0.0, 0.0], - "offset_quat": [1.0, 0.0, 0.0, 0.0], - "weight_scaler": 1.0, - "dofs": [] - }, - { - "name": "elbow", - "parent": "root", - "offset_pos": [1.0, 0.0, 0.0], - "offset_quat": [1.0, 0.0, 0.0, 0.0], - "weight_scaler": 1.0, - "dofs": [ - { - "axis": [0.0, 0.0, 1.0], - "type": "hinge", - "neutral": 0.0, - "weight_scaler": 1.0, - "limits": [-3.0, 3.0] - } - ] - }, + "name": "root", + "parent": null, + "offset_pos": [0.0, 0.0, 0.0], + "offset_quat": [1.0, 0.0, 0.0, 0.0], + "weight_scaler": 1.0, + "dofs": [] + }, + { + "name": "elbow", + "parent": "root", + "offset_pos": [1.0, 0.0, 0.0], + "offset_quat": [1.0, 0.0, 0.0, 0.0], + "weight_scaler": 1.0, + "dofs": [ { - "name": "wrist", - "parent": "elbow", - "offset_pos": [1.0, 0.0, 0.0], - "offset_quat": [1.0, 0.0, 0.0, 0.0], - "weight_scaler": 1.0, - "dofs": [] + "type": "hinge", + "axis": [0.0, 0.0, 1.0], + "neutral": 0.0, + "limits": [-3.0, 3.0], + "weight_scaler": 1.0 } ] + }, + { + "name": "wrist", + "parent": "elbow", + "offset_pos": [1.0, 0.0, 0.0], + "offset_quat": [1.0, 0.0, 0.0, 0.0], + "weight_scaler": 1.0, + "dofs": [] } - ``` - - - `fixed_base`: whether the root is fixed in the world rather than a free-floating base. Optional, defaults to `false`. - - `parent`: joint name, or `null` for the root. - - `offset_pos`/`offset_quat`: this joint's offset from its parent. - - `weight_scaler`: multiplied together with each frame's `KeypointObservation`'s `weight` for this joint's keypoint. Optional, defaults to `1.0`. - - `dofs`: this joint's degrees of freedom, each with: - - `type`: `"hinge"` (rotational) or `"slide"` (translational). - - `axis`: rotation/translation axis in local frame. - - `neutral`: neutral angle (radians) or position. - - `limits`: optional `[min, max]` limits; unbounded if omitted or `null`. - - `weight_scaler`: multiplied together with `SolverConfig`'s `weight` for this DOF's deviation-from-neutral penalty. Optional, defaults to `1.0`. - - See the [API reference](../api/rust/quickik/body_plan/index.html) for the full schema. + ], + "x-anything": [ + "Keys starting with 'x-' are allowed at any level and are ignored.", + "They can be of any type and are useful for custom metadata/documentation." + ] +} +``` \ No newline at end of file diff --git a/docs/getting-started/from-2d-keypoints.md b/docs/getting-started/from-2d-keypoints.md deleted file mode 100644 index 52392f6..0000000 --- a/docs/getting-started/from-2d-keypoints.md +++ /dev/null @@ -1,57 +0,0 @@ -# From 2D keypoint positions - -Not every tracking source gives 3D positions directly. A single camera view only ever gives 2D pixel coordinates, and recovering the underlying 3D pose is itself part of what the solve needs to do. `Position2D` observations cover this: set `SolverConfig`'s mapper to a `Camera` (a pinhole projection model – focal lengths, principal point, and the camera's own pose relative to the body plan's world frame) and QuickIK projects each candidate 3D keypoint position through it before comparing to the observed pixel coordinates, rather than comparing 3D positions directly. `XYView` covers the simpler case of keypoints already reprojected onto a physical X-Y plane (e.g. a top-down tracking setup) – no camera intrinsics/extrinsics involved, just the identity projection dropping Z. - -=== "Rust" - - ```rust - use quickik::observation::Camera; - use nalgebra::Matrix3; - - let camera = Camera { - fx: 800.0, - fy: 800.0, - cx: 320.0, - cy: 240.0, - world2cam_pos: Vector3::new(0.0, 0.0, 5.0), - world2cam_rot_mat: Matrix3::identity(), - }; - let config = SolverConfig { mapper: Some(camera), ..SolverConfig::default() }; - let mut solver: Solver = Solver::new(&kinematic_tree, config); - ``` - -=== "Python" - - ```python - camera = quickik.Camera( - fx=800.0, fy=800.0, cx=320.0, cy=240.0, - world2cam_pos=(0.0, 0.0, 5.0), - world2cam_rot_mat=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0), # row-major 3x3 - ) - solver = quickik.Solver(kinematic_tree, quickik.SolverConfig(), mapper=camera) - ``` - -=== "C++" - - ```cpp - quickik::Camera camera{}; - camera.fx = 800.0f; - camera.fy = 800.0f; - camera.cx = 320.0f; - camera.cy = 240.0f; - camera.world2cam_pos = {0.0f, 0.0f, 5.0f}; - camera.world2cam_rot_mat = {1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f}; // row-major 3x3 - auto solver = quickik::new_solver(*tree, quickik::default_solver_config(), quickik::camera_mapper(camera)); - ``` - -Rust's `Solver` is generic over the mapper type at compile time; neither Python nor C++ has an equivalent, so every `Solver`/`SequenceSolver` is backed by a single mapper value chosen at runtime instead – Python takes `mapper=None` (the default), a `Camera`, or an `XYView()` as a keyword argument; C++ takes a `quickik::Mapper` built via `no_mapper()`/`camera_mapper(camera)`/`xyview_mapper()` as an ordinary constructor argument in the same spots. It's deliberately not part of `SolverConfig` in either binding: like Rust's `M`, it's fixed for the solver's lifetime (read-only `mapper`/`solver.mapper` accessor, no setter), whereas `SolverConfig`'s other fields stay freely mutable. - -Errors from malformed input (bad JSON, wrong-sized vectors, a `Position2D` observation with no mapper set) don't crash either binding – both raise a catchable error instead. In C++, that's a `rust::Error` (a normal `std::exception`). In Python, it's `pyo3_runtime.PanicException`, which – deliberately, on PyO3's part – subclasses `BaseException`, not `Exception`; a bare `except Exception:` won't catch it, so code that needs to handle these needs `except BaseException:` (or the specific `PanicException` type) instead. - -## 2D vs. 3D fit - -The same NeuroMechFly recording solved twice with a `SequenceSolver` – once from full 3D keypoint observations, once from `XYView`-only (x/y) observations – overlaid to show what the missing depth information costs. Blue is the 3D fit, green is the `XYView` fit, gray dots are the raw MoCap keypoints; the floor-projected copy of each skeleton is the actual view the 2D fit was observed from. - - diff --git a/docs/installation.md b/docs/getting-started/installation.md similarity index 92% rename from docs/installation.md rename to docs/getting-started/installation.md index e1ce289..c187a1e 100644 --- a/docs/installation.md +++ b/docs/getting-started/installation.md @@ -17,7 +17,7 @@ git clone https://github.com/NeLy-EPFL/quickik === "Python" - The Python bindings (`python/`) are a [PyO3](https://pyo3.rs)/[maturin](https://github.com/PyO3/maturin) extension module, so they build from source and require a Rust toolchain plus Python >= 3.8. + The Python bindings (`python/`) are a [PyO3](https://pyo3.rs)/[maturin](https://www.maturin.rs/) extension module, so they build from source and require a Rust toolchain plus Python >= 3.8. ```sh cd quickik/python diff --git a/docs/getting-started/sequences.md b/docs/getting-started/sequences.md index 026282d..461eb36 100644 --- a/docs/getting-started/sequences.md +++ b/docs/getting-started/sequences.md @@ -1,80 +1,163 @@ # Solve sequences of frames -A single `solve()` call always starts from whatever `State` you pass it – usually the neutral pose, the first time. But real tracking data is a sequence of frames, and the previous frame's solved pose is almost always an excellent starting guess for the next one: real motion is continuous, so warm-starting from it both converges faster (Gauss-Newton starts much closer to the answer) and tracks more smoothly (it doesn't have to re-discover the same pose from scratch every frame, so it's less prone to landing in a different local optimum frame to frame). `SequenceSolver` automates exactly this: it keeps a `Solver` and `State` together and warm-starts each new frame from the last one it solved. +A single call to the solve method always starts from whatever state you pass it, usually the neutral pose. Real tracking data is typically a sequence of frames depicting continuous motion, so the previous frame's solved pose is almost always an excellent starting guess for the next one. To take advantage of this, QuickIK provides a `SequenceSolver` that automates the warm-starting process. As a result, the solver almost always converges faster. + +## Solving a whole sequence in one call + +Assuming you already have the whole recording upfront, the example below solves a whole sequence of frames in one call: === "Rust" ```rust + use std::sync::Arc; + use quickik::body_plan::KinematicTree; + use quickik::solver::SolverConfig; + use quickik::observation::KeypointObservation; use quickik::high_level::SequenceSolver; + let kinematic_tree = Arc::new(KinematicTree::from_json_file("body_plan.json")); let mut seq_solver = SequenceSolver::new(kinematic_tree.clone(), SolverConfig::default()); - for frame_observations in &recording { - let pose = seq_solver.solve_frame(frame_observations); - } + + // recording: Vec>. One inner Vec per frame, each n_joints long. + let recording = ...; + + let poses = seq_solver.solve_sequence(&recording); ``` === "Python" ```python - seq_solver = quickik.SequenceSolver(kinematic_tree, quickik.SolverConfig()) - for frame_observations in recording: - pose = seq_solver.solve_frame(frame_observations) + from quickik import KinematicTree, SequenceSolver, SolverConfig + + kinematic_tree = KinematicTree.from_json_file("body_plan.json") + seq_solver = SequenceSolver(kinematic_tree, SolverConfig()) + + # positions: NDArray of shape (n_frames, n_joints, 3), in kinematic_tree.joints order + positions = ... + # weights: NDArray of shape (n_frames, n_joints); 0 or below indicates keypoint is missing + weights = ... + + poses = seq_solver.solve_sequence(positions, weights) ``` + !!! note "Bigger practical performance win in Python" + Python's `solve_sequence` takes `positions`/`weights` NumPy arrays instead of a list of per-frame `KeypointObservation` lists, so it never constructs one Python object per keypoint per frame. That construction is what actually dominates call overhead for a long recording. + + Any dtype is accepted for `positions`/`weights` (e.g. the common case of a `float64` array) and cast to `float32`, following NumPy's own casting rules. + === "C++" ```cpp - auto seq_solver = quickik::new_sequence_solver(*tree, quickik::default_solver_config(), quickik::no_mapper()); - for (auto &frame_observations : recording) { - auto pose = seq_solver->solve_frame( - rust::Slice(frame_observations.data(), frame_observations.size())); - } + #include "quickik.h" + + auto tree = quickik::kinematic_tree_from_json_file("body_plan.json"); + auto seq_solver = quickik::new_sequence_solver( + *tree, quickik::default_solver_config(), quickik::no_mapper() + ); + + // flattened_recording is every frame's observations concatenated back to + // back: n_joints * n_frames long, frame i at [i * n_joints, (i + 1) * n_joints). + auto flattened_recording = ...; // std::vector + auto observations = rust::Slice( + flattened_recording.data(), flattened_recording.size() + ); + + auto poses = seq_solver->solve_sequence(observations, tree->n_joints()); ``` -A plain, sequential `SequenceSolver` like this only ever uses one thread, and one frame has to finish before the next can start (each one warm-starts from the last, after all). For a single long recording, `solve_sequence_segmented_parallel` gets around that: it splits the sequence into segments with a small overlap, solves every segment on its own worker thread (cold-started at each segment's own first frame, then warm-started within it), and stitches the results back into one continuous sequence. `overlap_tolerance` is a consistency check, not a correctness requirement – neighboring segments' overlapping frames were solved independently (one cold-started, one warm-started from a different point), so they can disagree slightly even on a real recording; exceeding this per-DOF angle tolerance (radians) just logs a warning; the resulting sequence itself is unaffected. A positive `n_workers` is used directly, unless it exceeds the number of available cores – in that case it's clipped to that count and a warning is logged. A negative value counts backward from all available cores: `-1` uses all, `-2` uses all but one, etc.; `0` is invalid. + !!! note "Explicit `n_joints` in C++" + `solve_sequence` needs `n_joints` explicitly in C++, since `flattened_recording` is one long slice rather than a list of per-frame slices – C++ has no nested-container binding across the FFI – so `n_joints` is the stride used to cut that one slice back into individual frames. + +## Solving long sequences in parallel + +A plain `SequenceSolver` only ever uses one thread, and each frame has to finish before the next can start, since every frame warm-starts from the last. For a single long recording, `solve_sequence_segmented_parallel` gets around that: it splits the recording into segments with small overlaps, solves each on its own worker thread (cold-started at the segment's first frame, then warm-started within it), and stitches the results back into one continuous sequence. The overlap does double duty: it gives every segment after the first a running start, since its own copy of the shared frames gets to warm up before it reaches genuinely new ones, and it doubles as a consistency check, since two independent solves of the same frames should agree closely. When they don't, by more than `overlap_tolerance`, a warning is logged and the earlier segment's version is kept. + +The parallel configuration bundles the segment length and overlap, a consistency-check tolerance, and the worker count: + +- **`segment_len`/`overlap_len`:** length of each segment and of the overlap between consecutive segments, in frames. +- **`overlap_tolerance`:** the per-DOF angle disagreement (radians) allowed between overlapping frames before logging the warning described above. +- **`n_workers`:** a positive value is used directly, clipped down (with a warning) if it exceeds the available core count. A negative value counts backward from all available cores: `-1` uses all, `-2` uses all but one, etc. `0` is invalid. + +The example below splits a long recording into segments explicitly: === "Rust" ```rust use quickik::high_level::{ParallelSolveConfig, solve_sequence_segmented_parallel}; - let parallel_config = ParallelSolveConfig { segment_len: 200, overlap_len: 20, overlap_tolerance: 0.05, n_workers: -1 }; - let poses = solve_sequence_segmented_parallel(&kinematic_tree, SolverConfig::default(), &long_recording, parallel_config); + let parallel_config = ParallelSolveConfig { + segment_len: 200, + overlap_len: 10, + overlap_tolerance: 0.05, + n_workers: -1, // -1 = all available threads, -2 = all but one, etc. + }; + + // long_recording: Vec>. One inner Vec per frame, each n_joints long. + let long_recording = ...; + + let poses = solve_sequence_segmented_parallel( + &kinematic_tree, + SolverConfig::default(), + &long_recording, + parallel_config, + ); ``` === "Python" ```python import numpy as np + from quickik import ParallelSolveConfig, solve_sequence_segmented_parallel - # positions: (n_frames, n_joints, 3) float32, in kinematic_tree.joints - # order. weights: (n_frames, n_joints) float32; a keypoint with - # weight <= 0 counts as Missing. - positions = np.zeros((len(long_recording), kinematic_tree.n_joints, 3), dtype=np.float32) - weights = np.ones((len(long_recording), kinematic_tree.n_joints), dtype=np.float32) - # ... fill positions/weights from long_recording ... - - parallel_config = quickik.ParallelSolveConfig( - segment_len=200, overlap_len=20, overlap_tolerance=0.05, n_workers=-1 + parallel_config = ParallelSolveConfig( + segment_len=200, + overlap_len=10, + overlap_tolerance=0.05, + n_workers=-1, # -1 = all available threads, -2 = all but one, etc. ) - poses = quickik.solve_sequence_segmented_parallel( - kinematic_tree, quickik.SolverConfig(), positions, weights, parallel_config + + # long_positions: NDArray of shape (n_frames, n_joints, 3), in kinematic_tree.joints order + long_positions = ... + # long_weights: NDArray of shape (n_frames, n_joints); 0 or below indicates keypoint is missing + long_weights = ... + + poses = solve_sequence_segmented_parallel( + kinematic_tree, SolverConfig(), long_positions, long_weights, parallel_config ) ``` - Python takes the whole sequence as `positions`/`weights` numpy arrays instead of a list of per-frame `KeypointObservation` lists, unlike `SequenceSolver.solve_frame` above: constructing one Python object per keypoint per frame is fine for a single frame at a time, but its overhead dominates once you're pushing a whole recording through in one call – see the [benchmarks page](../benchmarks.md)'s "QuickIK (Python/C++/Rust)" implementation note. + Like `solve_sequence` above, `long_positions`/`long_weights` accept any dtype and are cast to `float32`. === "C++" ```cpp - // flattened_long_recording is n_joints * n_frames long – see below. - quickik::ParallelSolveConfig parallel_config{200, 20, 0.05f, -1}; + // flattened_long_recording is n_joints * n_frames long, same flattened layout + // as solve_sequence + auto flattened_long_recording = ...; + + quickik::ParallelSolveConfig parallel_config{200, 10, 0.05f, -1}; + + // Wrap all observations in a Rust Slice view + auto observations = rust::Slice( + flattened_long_recording.data(), flattened_long_recording.size() + ); auto poses = quickik::solve_sequence_segmented_parallel( - *tree, quickik::default_solver_config(), - rust::Slice(flattened_long_recording.data(), flattened_long_recording.size()), - tree->n_joints(), parallel_config, quickik::no_mapper()); + *tree, + quickik::default_solver_config(), + observations, + tree->n_joints(), + parallel_config, + quickik::no_mapper(), + ); + + // poses is a StateList, not a std::vector. Read it out with .len()/.at(i). + for (size_t i = 0; i < poses->len(); i++) { + auto pose = poses->at(i); + } ``` - C++ has no nested-container binding across the FFI, so a "sequence" is one flat `observations` slice of length `n_joints * n_frames` (frame `i` spanning `[i * n_joints, (i + 1) * n_joints)`) rather than a list of lists, and `solve_sequence`/`solve_sequence_segmented_parallel` return a `StateList` handle (`len()`/`at(i)`) instead of a `Vec`/`list[State]`. See `cpp/src/lib.rs`'s module docs. +If you'd rather not tune `segment_len`/`overlap_len` yourself, a `for_recording` constructor (C++: the free function `parallel_solve_config_for_recording`) builds a `ParallelSolveConfig` that spreads `total_len` frames evenly across every available core – one segment per core, sized by simple division plus a fixed default overlap. Build a `ParallelSolveConfig` directly, as above, for finer control over cold-start frequency. + +In C++, this takes the same flattened-slice-plus-`n_joints` layout as `solve_sequence` above (there's no way to pass a list of per-frame observation lists directly across the FFI). The results come back as a `StateList` rather than a plain vector, as shown above. -Independent sequences (e.g. one per subject or one per camera) don't need this machinery at all – just solve each with its own `SequenceSolver` and parallelize however you like (a thread pool, `rayon`, Python's `multiprocessing`, ...). +Independent sequences (e.g. one per subject or one per camera) don't need this machinery. Just solve each with its own `SequenceSolver` and parallelize however you like (a thread pool, Rust's [Rayon](https://docs.rs/rayon/latest/rayon/), Python's [multiprocessing](https://docs.python.org/3/library/multiprocessing.html) or [Joblib](https://joblib.readthedocs.io/), etc.). diff --git a/docs/getting-started/single-frame.md b/docs/getting-started/single-frame.md index 1c2defd..cbc799d 100644 --- a/docs/getting-started/single-frame.md +++ b/docs/getting-started/single-frame.md @@ -1,21 +1,84 @@ -# Solve pose for single frame +# Solve pose for a single frame -QuickIK solves *whole-tree* IK: one `Solver::solve` call takes one `KeypointObservation` per keypoint – `Missing`, `Position3D`, or `Position2D` (see [From 2D keypoint positions](from-2d-keypoints.md)) – in `kinematic_tree.joints` order, and jointly fits every joint angle at once against all of them – plus the root pose too, unless the body plan's root is [fixed](body-plan.md). This is what makes it different from solving each limb as its own small IK problem: a keypoint on one limb can still help constrain the root pose (and therefore every other limb) even if that other limb's own keypoints are all `Missing` this frame. +## Setting up a solver + +A solver is built once from a kinematic tree (loaded from a [body plan](body-plan.md)) and a solver configuration object, then reused across every frame you solve. + +The solver configuration bundles the iteration count, regularization weight, convergence tolerance, and damping: + +- **`n_iterations`:** how many Gauss-Newton steps to run per solve call, and the cap early stopping can cut short. +- **`neutral_weight`:** how strongly every joint angle is pulled toward its neutral pose, multiplied with each DOF's own `weight_scaler` from the body plan. This is what keeps `Missing` keypoints, and under-constrained DOFs generally, from drifting to an arbitrary angle, at the cost of some bias where that DOF *is* observed. +- **`position_tolerance`/`angle_tolerance`:** stop iterating early once an update step's largest position and angle components both drop below these. `0` disables early stopping. +- **`damping`:** Levenberg-Marquardt damping added to the normal equations' diagonal, for numerical stability only. Keep it very small (default `1e-6`). +- **`mapper`:** used for keypoint positions given in 2D projections (see ["From 2D keypoint positions"](2d-keypoints.md)). By default, it is `NoMapper` for 3D keypoint positions (as is the case here). + +It stays mutable for retuning between calls: in Rust and Python it's a live handle attached to the solver, so changing a field takes effect on the next solve. C++'s configuration is a plain value struct instead, with no shared live handle: mutate a copy and pass it back to the solver to apply it. + +The example below loads a body plan, then creates a solver with the default configuration and a state initialized to the neutral pose: === "Rust" ```rust use std::sync::Arc; use quickik::body_plan::KinematicTree; - use quickik::observation::KeypointObservation; use quickik::solver::{Solver, SolverConfig}; use quickik::state::State; + use quickik::observation::KeypointObservation; use nalgebra::Vector3; let kinematic_tree = Arc::new(KinematicTree::from_json_file("body_plan.json")); + let mut solver_config = SolverConfig::default(); + let mut solver: Solver = Solver::new(&kinematic_tree, solver_config); + + // Construct a mutable state once, reuse across many solves (to be used later) let mut state = State::neutral_pose(kinematic_tree.clone()); - let mut solver: Solver = Solver::new(&kinematic_tree, SolverConfig::default()); + ``` +=== "Python" + + ```python + from quickik import KinematicTree, State, Solver, SolverConfig, KeypointObservation + + kinematic_tree = KinematicTree.from_json_file("body_plan.json") + solver_config = SolverConfig() + solver = Solver(kinematic_tree, solver_config) + + # Initiate a state object once, reuse across many solves (to be used later) + state = State.neutral_pose(kinematic_tree) + ``` + +=== "C++" + + ```cpp + #include + #include "quickik.h" + + auto tree = quickik::kinematic_tree_from_json_file("body_plan.json"); + auto solver_config = quickik::default_solver_config(); + auto solver = quickik::new_solver( + *tree, + solver_config, + quickik::no_mapper(), // mapper must be written out explicitly in C++ + ); + + // Initiate a state object once, reuse across many solves (to be used later) + auto state = quickik::state_neutral_pose(*tree); + ``` + +## Solving a frame + +To fit a pose, call the solver's solve method with the state to update and a list of keypoint observations, one per keypoint, in the body plan's joint order. An observation must be given for every keypoint, but some can be of type `Missing`. QuickIK supports keypoint observations in both a 3D and 2D (see ["From 2D keypoint positions"](2d-keypoints.md)). For now, we will use 3D keypoints. + +The solve method jointly fits every joint angle, plus the root pose (unless the root is fixed in the body plan), against all of the observations at once. This is what differentiates QuickIK from inverse kinematics in its traditional definition: instead of solving for only the end effector (e.g., hand or foot), and doing so independently for each kinematic chain (e.g., limb), we take all keypoints on the whole body into consideration in a single solve.[^1] This way, the keypoints help constrain one another and make QuickIK more robust to missing and 2D observations. + +[^1]: + Though QuickIK is not the only library that does this: for example, see [Pinocchio](https://stack-of-tasks.github.io/pinocchio/) and [RBDL](https://github.com/rbdl/rbdl) in our [benchmark tests](../technical/benchmarks.md). + +Continuing the example above with three observed keypoint positions: + +=== "Rust" + + ```rust let observations = vec![ KeypointObservation::Position3D { obs_pos: Vector3::new(0.0, 0.0, 0.0), weight: 1.0 }, KeypointObservation::Position3D { obs_pos: Vector3::new(1.0, 0.0, 0.0), weight: 1.0 }, @@ -28,16 +91,10 @@ QuickIK solves *whole-tree* IK: one `Solver::solve` call takes one `KeypointObse === "Python" ```python - import quickik - - kinematic_tree = quickik.KinematicTree.from_json_file("body_plan.json") - state = quickik.State.neutral_pose(kinematic_tree) - solver = quickik.Solver(kinematic_tree, quickik.SolverConfig()) - observations = [ - quickik.KeypointObservation.position_3d((0.0, 0.0, 0.0), 1.0), - quickik.KeypointObservation.position_3d((1.0, 0.0, 0.0), 1.0), - quickik.KeypointObservation.position_3d((1.0, 1.0, 0.0), 1.0), + KeypointObservation.position_3d((0.0, 0.0, 0.0), 1.0), + KeypointObservation.position_3d((1.0, 0.0, 0.0), 1.0), + KeypointObservation.position_3d((1.0, 1.0, 0.0), 1.0), ] solver.solve(state, observations) print(state.dof_angles) @@ -46,28 +103,24 @@ QuickIK solves *whole-tree* IK: one `Solver::solve` call takes one `KeypointObse === "C++" ```cpp - #include "quickik.h" - - auto tree = quickik::kinematic_tree_from_json_file("body_plan.json"); - auto state = quickik::state_neutral_pose(*tree); - auto solver = quickik::new_solver(*tree, quickik::default_solver_config(), quickik::no_mapper()); - std::vector observations = { quickik::keypoint_position_3d({0.0, 0.0, 0.0}, 1.0), quickik::keypoint_position_3d({1.0, 0.0, 0.0}, 1.0), quickik::keypoint_position_3d({1.0, 1.0, 0.0}, 1.0), }; - solver->solve(*state, rust::Slice(observations.data(), observations.size())); - for (float angle : state->dof_angles()) { /* ... */ } + // Wrap this frame's observations in a Rust Slice view + auto observations = rust::Slice( + observations.data(), observations.size() + ); + + solver->solve(*state, observations); + + for (float angle : state->dof_angles()) { + std::cout << angle << " "; + } + std::cout << std::endl; ``` -`Missing` keypoints (occluded this frame, or simply not tracked by this body plan's data source) don't just get dropped from the residual – with nothing pulling them away, the solve falls back on `SolverConfig`'s neutral-pose prior for whatever DOFs only those keypoints could otherwise constrain, so a body with everything missing settles at its neutral pose rather than an arbitrary one. - -`SolverConfig` bundles the iteration count, damping, regularization weight, and convergence tolerance: - -- `n_iterations`: how many Gauss-Newton steps to run per `solve` call, and the cap early stopping can cut short. -- `damping`: Levenberg-Marquardt damping added to the normal equations' diagonal, for numerical stability only – keep it very small (the default is `1e-6`). -- `weight`: how strongly every joint angle is pulled toward its neutral pose, multiplied together with each DOF's own `weight_scaler` from the body plan. This is what keeps `Missing` keypoints (and, more generally, under-constrained DOFs) from drifting to an arbitrary angle instead of a sensible default, at the cost of some bias on frames where that DOF *is* observed. -- `position_tolerance`/`angle_tolerance`: stop iterating early once an update step's largest position and angle components both drop below these; `0` disables early stopping. +The `solve` method updates the `State` object in place, so the fitted joint angles and root pose are read back off the same state object afterward. -It's set via `Solver::new`/`Solver(...)`, though `solver.config` stays mutable for retuning between calls (Python: `solver.config` is the same object every time, so `solver.config.n_iterations = 5` takes effect on the next `solve`, just like Rust). C++'s `SolverConfig` is a plain value struct instead (no shared live handle): mutate a copy and call `solver->set_config(config)` to apply it. +`Missing` keypoints don't just drop out of the fit: with nothing pulling them away, the solve falls back on the solver configuration's neutral-pose prior for any DOF only those keypoints could otherwise constrain. A body with everything missing settles at its neutral pose rather than an arbitrary one. diff --git a/docs/index.md b/docs/index.md index 97d52bf..0492bf4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,19 +4,29 @@ icon: lucide/house # QuickIK -Fast inverse kinematics library, aimed for both high throughput and low latency. +QuickIK is a fast forward and inverse kinematics library. It provides high-level APIs for processing consecutive frames with warm starts and multi-threaded batch processing, as well as a low-level API for more specific use cases (e.g., real-time applications). QuickIK is written in Rust but comes with Python and C++ bindings. -QuickIK solves *whole-tree* inverse kinematics: given a robot or animal with a free-floating base (e.g. a pelvis or thorax not bolted to anything) and many tracked keypoints spread across multiple limbs, it finds the one root pose + joint-angle vector that best matches every keypoint's target position, jointly, in a single solve. +## Feature highlights -It provides high-level APIs for processing consecutive frames with warm starts and multi-threaded batch processing, as well as a low-level API for more specific use cases (e.g. real-time applications) – in Rust, with Python and C++ bindings. +- **Whole-body kinematics:** Traditionally, inverse kinematics solves for joint angles to match the position of only the end effector (e.g., foot). QuickIK finds the joint angles and base state that best matches many tracked keypoints, possibly spread across multiple kinematic chains (e.g., limbs), in a single solve. +- **Favoring "natural" poses:** QuickIK can be configured with a bias to favor more neutral joint angles when the problem is underconstrained. +- **Incomplete observation:** QuickIK allows some keypoints to be missing on some of the frames and does its best using only the available ones. +- **From 2D keypoint positions:** QuickIK can accept keypoint positions that are only in 2D projections. The problem is intrinsically underconstrained, but it can still work if the camera angle is reasonable and the pull toward "natural" states is properly tuned. +- **Differential weights:** When not all keypoint positions are equally reliable in the upstream MoCap data, QuickIK can consider them with different weights. +- **Very fast:** QuickIK is [>5x faster](technical/benchmarks.md) than RBDL/Pinocchio. ## Example -The following video shows QuickIK's solution to two inverse kinematics tasks used in the [benchmarks](benchmarks.md): +The following video shows QuickIK's solution to two inverse kinematics tasks used in the [benchmarks](technical/benchmarks.md): -- **Biomechanics:** [Behavior recording](https://nely-epfl.github.io/spotlight-poseforge-paper/) of a fruit fly retargeted to [NeuroMechFly](https://neuromechfly.org/) -- **Robotics:** [LAFAN1](https://github.com/ubisoft/ubisoft-laforge-animation-dataset) walking kinematics retargeted to a [Unitree G1](https://www.unitree.com/g1) humanoid robot (larger keypoint mismatch is expected due to greater difference between robotic and human bodies) +- **Biomechanics:** [Behavior recording](https://nely-epfl.github.io/spotlight-poseforge-paper/) of a fruit fly retargeted to the [NeuroMechFly](https://neuromechfly.org/) model +- **Robotics:** [LAFAN1](https://github.com/ubisoft/ubisoft-laforge-animation-dataset) walking kinematics retargeted to a [Unitree G1](https://www.unitree.com/g1) humanoid robot (greater mismatch expected due to larger difference between robotic and human bodies) -