From ea124b7ded9c05c67be72baa333ba9d2ef460e53 Mon Sep 17 00:00:00 2001 From: Sibo Wang-Chen Date: Sun, 26 Jul 2026 22:51:19 +0200 Subject: [PATCH 01/12] Improve docs --- .github/workflows/docs.yml | 2 - benchmark/README.md | 2 +- benchmark/extern/kdl/README.md | 2 +- benchmark/extern/pinocchio/README.md | 2 +- benchmark/extern/rbdl/README.md | 2 +- devtools-pyenv/pyproject.toml | 1 + devtools-pyenv/uv.lock | 241 ++++++++++++++++ docs/benchmarks.md | 37 --- docs/build.sh | 8 +- docs/getting-started/2d-keypoints.md | 58 ++++ docs/getting-started/body-plan.md | 125 ++++---- docs/getting-started/from-2d-keypoints.md | 57 ---- docs/{ => getting-started}/installation.md | 2 +- docs/getting-started/sequences.md | 149 +++++++--- docs/getting-started/single-frame.md | 113 ++++++-- docs/index.md | 25 +- docs/technical/benchmarks.md | 39 +++ etc/bodyplan_20260726.schema.json | 118 ++++++++ python/uv.lock | 319 +++++++++++++++++++++ zensical.toml | 23 +- 20 files changed, 1088 insertions(+), 237 deletions(-) delete mode 100644 docs/benchmarks.md create mode 100644 docs/getting-started/2d-keypoints.md delete mode 100644 docs/getting-started/from-2d-keypoints.md rename docs/{ => getting-started}/installation.md (92%) create mode 100644 docs/technical/benchmarks.md create mode 100644 etc/bodyplan_20260726.schema.json create mode 100644 python/uv.lock 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/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/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/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/devtools-pyenv/pyproject.toml b/devtools-pyenv/pyproject.toml index 55eb4b1..dd29468 100644 --- a/devtools-pyenv/pyproject.toml +++ b/devtools-pyenv/pyproject.toml @@ -10,4 +10,5 @@ dependencies = [ "pytest>=9.1.1", "ruff>=0.15.22", "scipy>=1.18.0", + "zensical>=0.0.51", ] diff --git a/devtools-pyenv/uv.lock b/devtools-pyenv/uv.lock index f048799..bb8d012 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" @@ -97,6 +118,7 @@ dependencies = [ { name = "pytest" }, { name = "ruff" }, { name = "scipy" }, + { name = "zensical" }, ] [package.metadata] @@ -107,6 +129,7 @@ requires-dist = [ { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.22" }, { name = "scipy", specifier = ">=1.18.0" }, + { name = "zensical", specifier = ">=0.0.51" }, ] [[package]] @@ -159,6 +182,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 +280,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 +576,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 +626,52 @@ 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 = "ruff" version = "0.15.22" @@ -590,3 +756,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..cea6ab7 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 solver favors poses that are closer to this is the "natural" rotation angle or slide position. For slide joints, the value is in radians; for hinge DOFs, the value is given in or whatever unit the joint's position offset is given in. +- A **weight scaler** controlling the _scale_[^3] of how strongly the solver favors the neutral state defined above. +- 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. + +[^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 you 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], + "type": "hinge", + "axis": [0.0, 0.0, 1.0], + "neutral": 0.0, "weight_scaler": 1.0, - "dofs": [] + "limits": [-3.0, 3.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..b31f51b 100644 --- a/docs/getting-started/sequences.md +++ b/docs/getting-started/sequences.md @@ -1,80 +1,159 @@ # 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. + === "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. - === "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..42a0ad2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,19 +4,30 @@ 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 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. -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. +## Feature highlights + +- **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) -