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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ QuickIK provides high-level APIs for processing consecutive frames (i.e. with wa

See the [docs site](https://nely-epfl.github.io/quickik/) for installation, usage examples, and benchmarks against KDL, Pinocchio, and RBDL.

Not yet published to crates.io or PyPI – see [`docs/installation.md`](docs/installation.md) for building from a local clone.
Not yet published to crates.io or PyPI – see [`docs/getting-started/installation.md`](docs/getting-started/installation.md) for building from a local clone.

## For developers

Expand All @@ -21,8 +21,8 @@ cd devtools-pyenv && uv sync && source .venv/bin/activate
### Build

- **Rust core**: `cargo build` (add `--workspace` to also build the `python`/`cpp` bindings crates and the `benchmark/` crates).
- **Python bindings**: `cd python && maturin develop --release` – see [`docs/installation.md`](docs/installation.md) for prerequisites.
- **C++ bindings**: CMake, from `cpp/` – see [`docs/installation.md`](docs/installation.md).
- **Python bindings**: `cd python && maturin develop --release` – see [`docs/getting-started/installation.md`](docs/getting-started/installation.md) for prerequisites.
- **C++ bindings**: CMake, from `cpp/` – see [`docs/getting-started/installation.md`](docs/getting-started/installation.md).

### Lint/format

Expand Down
2 changes: 1 addition & 1 deletion benchmark/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 2 additions & 3 deletions benchmark/assets/g1_body_plan.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"metadata": {
"name": "g1_29dof"
},
"fixed_base": false,
"x-name": "g1_29dof",
"joints": [
{
"name": "pelvis",
Expand Down
2 changes: 1 addition & 1 deletion benchmark/assets/neuromechfly_ypr_legs.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion benchmark/extern/kdl/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
37 changes: 15 additions & 22 deletions benchmark/extern/kdl/bench_kdl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -396,23 +396,12 @@ void write_results_json(const std::string &body, double single_frame_latency_us,
"uses simple contiguous chunking (8 independent, "
"internally-warm-started, externally-cold-started chunks), not "
"QuickIK's overlap-stitched segmented solve, since KDL has no "
"parallel solve path to mirror. single_frame_latency_max_us forces "
"max_iter=10 (not this file's own kMaxIter=100 ceiling) so the "
"forced-worst-case number is comparable to quickik/RBDL/Pinocchio's, "
"which all cap at 10.";
if (body == "g1") {
notes +=
" Unlike the fly, G1's step-size early-stop NEVER triggers within "
"kMaxIter=100 -- every single_frame_latency_us call burns the full "
"100 iterations, landing at a real but looser ~3-4mm position "
"residual (vs. the fly's ~10-micron near-exact fit), which is why "
"single_frame_latency_us (100 real iterations) is larger than "
"single_frame_latency_max_us (only 10 forced iterations) for this "
"body -- backwards from every other body/library combination in "
"this comparison, and not a bug: it reflects KDL's own actual "
"default behavior being worse than the shared 10-iteration cap on "
"this harder problem, not an inconsistency in the measurement.";
}
"parallel solve path to mirror. Both single_frame_latency_us and "
"single_frame_latency_max_us force max_iter=10 (not this file's own "
"kMaxIter=100 ceiling, which only bounds the warm-started throughput "
"sequence below) so both are comparable to quickik/RBDL/Pinocchio's, "
"which also cap at 10; only step_tol differs (kStepTol vs. 0, i.e. "
"early stopping allowed vs. forced to the full 10 iterations).";
out << "{\n"
<< " \"name\": \"kdl\",\n"
<< " \"body\": \"" << body << "\",\n"
Expand Down Expand Up @@ -457,13 +446,17 @@ void run_body(const BodyConfig &body, const std::filesystem::path &assets_dir) {
auto target = to_vec3s(fixtures["synthetic_frames"][0]["target_ego"]);
Frames target_frames = build_target_frames(sb.model, target);

// max_iter is forced to 10 here (not this file's own kMaxIter=100) to
// match QuickIK/RBDL/Pinocchio's shared iteration cap -- early stopping
// (step_tol=kStepTol, the default) still applies within that budget.
std::printf("-- single-frame time (latency) --\n");
double single_frame_latency_us = summarize(
"CartToJnt() (cold)", bench_single_frame_latency(sb, q_neutral, target_frames, kLatencyNCalls, 500));
double single_frame_latency_us =
summarize("CartToJnt() (cold)",
bench_single_frame_latency(sb, q_neutral, target_frames, kLatencyNCalls, 500, /*max_iter=*/10));

// step_tol=0 disables early stopping; max_iter is forced to 10 here (not
// this file's own kMaxIter=100) to match QuickIK/RBDL/Pinocchio's shared
// iteration cap -- the worst case if a frame never converges early.
// step_tol=0 additionally disables early stopping, forcing every solve to
// run the full 10 iterations -- the worst case if a frame never converges
// early.
std::printf("\n-- single-frame time (latency), early stop disabled (10 iterations) --\n");
double single_frame_latency_max_us =
summarize("CartToJnt() (forced max iterations)",
Expand Down
2 changes: 1 addition & 1 deletion benchmark/extern/pinocchio/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion benchmark/extern/rbdl/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
114 changes: 12 additions & 102 deletions benchmark/plot/plot_comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,6 @@
},
]

# Metric key -> the library whose bar, in *both* bodies, should be drawn
# capped at CAP_MULTIPLE-x the next-highest bar's value (the max across
# every other library's bar in either body) instead of running out to its
# true value, which would flatten every other bar down near zero on a
# linear axis -- see `draw_body_bars`. Only KDL's mean-latency result is
# extreme enough (up to ~60x the next-highest bar, for g1) to need this;
# everywhere else a plain, uncapped bar is clearer.
CAPPED_METRIC_BARS = {"single_frame_latency_us": "kdl"}
CAP_MULTIPLE = 2

# Explicit display order (not alphabetical): language variants of the same
# library grouped together, fastest-per-library first.
ORDER = [
Expand Down Expand Up @@ -155,7 +145,14 @@ def load_results():
data = json.loads(path.read_text())
# quickik-scaling.json is a list of weak-scaling data points (see
# plot_scaling.py), not a single-library result -- skip it here.
if isinstance(data, dict):
# errors-<body>.json is plot_2d_comparison.py's own per-frame
# fit-residual distribution data (no "name"/"formulation" keys),
# not a per-library result either -- skip that too. So is
# quickik-rust-2d-xyview-<body>.json (an "observation" override of
# quickik-rust's own 3D result, also only used by
# plot_2d_comparison.py) -- without this it'd show up here as a
# second, indistinguishable "quickik-rust" row.
if isinstance(data, dict) and "name" in data and "observation" not in data:
all_results.append(data)
included = [r for r in all_results if r.get("formulation") == "whole-tree"]
excluded = [r for r in all_results if r not in included]
Expand Down Expand Up @@ -303,24 +300,6 @@ def register_fonts():
BAR_OFFSET = 0.20
BAR_HEIGHT = 0.40

# A capped bar's "keeps going" dashes past its tip (see draw_body_bars):
# DASH_COUNT blocks, each DASH_WIDTH*cap_length wide, separated by
# DASH_GAP*cap_length (also used as the gap right after the bar's tip).
# Sized so the whole run -- DASH_GAP + DASH_COUNT*DASH_WIDTH +
# (DASH_COUNT-1)*DASH_GAP, as a fraction of cap_length -- lands comfortably
# inside XLIM_PAD's headroom past the widest bar.
DASH_COUNT = 5
DASH_WIDTH = 0.012
DASH_GAP = 0.01


def dash_positions(cap_length):
"""Left-edge x-positions for DASH_COUNT dashes continuing a capped bar
of length `cap_length` past its own tip."""
step = (DASH_WIDTH + DASH_GAP) * cap_length
first = cap_length + DASH_GAP * cap_length
return [first + i * step for i in range(DASH_COUNT)]


def draw_body_bars(
ax,
Expand All @@ -332,37 +311,18 @@ def draw_body_bars(
unit,
small_unit,
color,
cap_lib=None,
cap_length=None,
):
"""Draws one body's bars (one per library in `lib_names`, at the matching
`y_positions`) on `ax`, skipping libraries with no value for `key`, and
annotates each with its value (see `format_value`) just past its tip --
plain "xxx unit", no parens.

If `cap_lib` names one of `lib_names` and `cap_length` is given, that
bar is instead drawn at the (already-scaled) `cap_length` rather than
its true length -- since running it out to its true value would flatten
every other bar down near zero on a linear axis. The caller computes
`cap_length` once per metric (shared across both bodies -- see
CAPPED_METRIC_BARS), so both bodies' bars for `cap_lib` end up the same
length even though their true values differ. That one bar's true value
plus "(capped in chart)" is written inside it, and a few small
same-color, same-height blocks continue past its tip (see
dash_positions()) -- a dashed-line cue that the bar keeps going -- in
place of the plain label every other bar gets there. Those blocks are
deliberately excluded from this function's returned widest extent (they
size themselves off of it, via dash_positions(cap_length)), so they land
inside the caller's XLIM_PAD headroom rather than pushing it out further.

Every QuickIK bar is drawn at QUICKIK_ALPHA with its value label bold;
every other library's bar gets OTHER_ALPHA and a plain-weight label.

Returns the widest x-extent actually drawn (unpadded, so the caller
decides how much room to leave past it), or 0.0 if no library had data.
"""
from matplotlib.patches import Rectangle

present = [
(y, lib, results_by_name[lib][key] * scale)
for y, lib in zip(y_positions, lib_names, strict=True)
Expand All @@ -373,44 +333,14 @@ def draw_body_bars(

ys = [y for y, _, _ in present]
values = [v for _, _, v in present]
display_values = list(values)
cap_idx = next((i for i, (_, lib, _) in enumerate(present) if lib == cap_lib), None)
if cap_idx is not None and cap_length is not None:
display_values[cap_idx] = cap_length

bars = ax.barh(ys, display_values, height=BAR_HEIGHT, color=color, zorder=3)
for i, (bar, (_, lib, _)) in enumerate(zip(bars, present, strict=True)):
bars = ax.barh(ys, values, height=BAR_HEIGHT, color=color, zorder=3)
for bar, (_, lib, value) in zip(bars, present, strict=True):
is_quickik = lib.startswith("quickik-")
bar.set_alpha(QUICKIK_ALPHA if is_quickik else OTHER_ALPHA)
weight = "bold" if is_quickik else "normal"
text = format_value(values[i], unit, small_unit)
if i == cap_idx and cap_length is not None:
ax.annotate(
f"{text} (capped in chart)",
(bar.get_width(), bar.get_y() + bar.get_height() / 2),
xytext=(-4, 0),
fontsize=TEXT_NUMBER_FONTSIZE,
fontweight=weight,
textcoords="offset points",
ha="right",
va="center",
zorder=4,
)
for dash_x in dash_positions(cap_length):
ax.add_patch(
Rectangle(
(dash_x, bar.get_y()),
DASH_WIDTH * cap_length,
bar.get_height(),
facecolor=color,
alpha=bar.get_alpha(),
edgecolor="none",
zorder=3,
)
)
continue
ax.annotate(
text,
format_value(value, unit, small_unit),
(bar.get_width(), bar.get_y() + bar.get_height() / 2),
xytext=(4, 0),
fontsize=TEXT_NUMBER_FONTSIZE,
Expand All @@ -420,7 +350,7 @@ def draw_body_bars(
va="center",
zorder=4,
)
return max(display_values)
return max(values)


def plot_chart(results_by_body):
Expand Down Expand Up @@ -483,18 +413,6 @@ def plot_chart(results_by_body):
metric["scale"],
)

cap_lib = CAPPED_METRIC_BARS.get(key)
cap_length = None
if cap_lib:
other_values = [
r[key]
for by_name in (nmf_by_name, g1_by_name)
for name, r in by_name.items()
if name != cap_lib and r.get(key) is not None
]
if other_values:
cap_length = CAP_MULTIPLE * max(other_values) * scale

nmf_ys = [y + BAR_OFFSET for y in group_ys]
g1_ys = [y - BAR_OFFSET for y in group_ys]
widest_nmf = draw_body_bars(
Expand All @@ -507,8 +425,6 @@ def plot_chart(results_by_body):
unit,
small_unit,
NEUROMECHFLY_COLOR,
cap_lib,
cap_length,
)
widest_g1 = draw_body_bars(
ax,
Expand All @@ -520,8 +436,6 @@ def plot_chart(results_by_body):
unit,
small_unit,
G1_COLOR,
cap_lib,
cap_length,
)
widest = max(widest_nmf, widest_g1)
if widest == 0.0:
Expand All @@ -546,10 +460,6 @@ def plot_chart(results_by_body):
label.set_fontweight("bold")
ax.set_ylim(-0.5, len(ordered_libs) - 1 + 0.5)
despine(ax)
# Even a capped bar's tip gets the usual padding now: its "..."
# label past the tip (see draw_body_bars) needs the room, and the
# in-bar "(capped in chart)" text already marks it as truncated, so
# a flush-to-the-edge tip is no longer the only cue for that.
ax.set_xlim(0, widest * XLIM_PAD)

legend_handles = [
Expand Down
2 changes: 1 addition & 1 deletion benchmark/plot/render_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ def solve_sequence(tree, fixtures, missing_keypoints=(), weight=None):
config = (
quickik.SolverConfig()
if weight is None
else quickik.SolverConfig(weight=weight)
else quickik.SolverConfig(neutral_weight=weight)
)
seq = quickik.SequenceSolver(tree, config)
return [
Expand Down
4 changes: 2 additions & 2 deletions benchmark/plot/render_video_2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def solve_sequence_xyview(tree, fixtures):
"""Warm-started XYView solve, mirroring `render_video.solve_sequence` but
with a `quickik.XYView()` mapper, 2D observations, and a stronger
neutral-pose prior -- see `WEIGHT_2D_XYVIEW`."""
config = quickik.SolverConfig(weight=WEIGHT_2D_XYVIEW)
config = quickik.SolverConfig(neutral_weight=WEIGHT_2D_XYVIEW)
seq = quickik.SequenceSolver(tree, config, mapper=quickik.XYView())
return [
seq.solve_frame(build_observations_2d_xyview(f["target_ego"]))
Expand Down Expand Up @@ -325,7 +325,7 @@ def bones(key, color, width, alpha):
body["lo"][2],
"X-Y projection",
size=x_range * 0.06,
color="0.4",
color="0.7",
)
return artists

Expand Down
Loading
Loading