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
68 changes: 47 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,54 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
# Single interpreter (python 3.11): the graphics/ML closure grl_snam_lab
# needs — pycvc-gl (the scriptable-scene bindings), numpy-cp311 and
# torch-cp311 — are cp311 columns in the cvcpkg catalog. Multi-interpreter
# coverage follows the cp312/cp313 columns of that closure.
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6

# Install grl-snam's whole dependency closure (build + runtime) from the
# cvcpkg catalog into a prefix: python311, pycvc-gl (which pulls pycvc +
# libcvc + cvcgl + vtk transitively), numpy + torch (CPU), and the
# pytest/ruff/black test/lint tools (grl-snam's depends.build). This
# replaces the old `pip install`: pip cannot install pycvc-gl (a cvcpkg
# package, not on PyPI), so `from pycvc_gl... import *` used to
# ModuleNotFoundError in CI. The recipe is the single source of truth for
# the closure (`install-deps <recipe>`), so there is no requirements file.
- name: Install dependency closure via cvcpkg
id: deps
uses: transfix/libcvc-deps/.github/actions/cvcpkg-install@master
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install package and dev tools
# Install via pip with the CPU-only PyTorch index to keep CI fast and
# avoid pulling multi-GB CUDA wheels.
run: |
python -m pip install --upgrade pip
pip install --index-url https://download.pytorch.org/whl/cpu torch
pip install -e .
pip install pytest pytest-cov ruff black
- name: Lint
recipe: cvcpkg/recipes/grl-snam
build_type: Release

- name: Lint + test
env:
PREFIX: ${{ steps.deps.outputs.path }}
run: |
black --check grl_snam tests
ruff check .
- name: Test
run: pytest -q
set -euo pipefail
if [ -z "${PREFIX}" ]; then
echo "::error::cvcpkg install-deps failed to resolve the grl-snam closure."
echo "::error::Is it published? Needs pycvc-gl +cvc.6 (package layout),"
echo "::error::torch-cp311, and pytest/ruff/black in the cvcpkg catalog."
exit 1
fi
PY="${PREFIX}/bin/python3.11"

# Test THIS checkout's grl_snam / grl_snam_lab (the PR's code); the
# compiled pycvc_gl + numpy + torch come from the cvcpkg prefix.
export PYTHONPATH="${PWD}:${PYTHONPATH:-}"
# The compiled bindings (_pycvc_gl / _pycvc) link libcvc.so.3, cvcGL,
# VTK etc. from the prefix's lib/, and we run the prefix python without
# sourcing its activate script, so put the prefix lib dir on the loader
# path — otherwise `import pycvc_gl` fails with
# "libcvc.so.3: cannot open shared object file".
export LD_LIBRARY_PATH="${PREFIX}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"

echo "== black --check =="
"${PY}" -m black --check grl_snam tests
echo "== ruff check =="
"${PREFIX}/bin/ruff" check .
echo "== pytest =="
"${PY}" -m pytest -q
16 changes: 16 additions & 0 deletions cvcpkg/recipes/grl-snam/recipe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ patches: []
depends:
build:
- name: python311
# Test/lint tools for the `test:` block below. cvcpkg has no separate
# test-deps group, so they are depends.build edges: staged into the build
# prefix and stripped from the shipped bundle (never a runtime dependency
# of grl-snam). Recipes live in libcvc-deps/recipes/{pytest,ruff,black}.
- name: pytest
- name: ruff
- name: black
runtime:
- name: python311
# The lab imports pycvc + pycvc_gl (the scriptable scene). Those bindings
Expand All @@ -66,6 +73,15 @@ build:
- platform: any
script: build.sh

# Bundle self-test — the cvcpkg-native counterpart to the GRL-SNAM CI test job.
# Runs `pytest -q` under the build prefix (where depends.build staged pytest/
# ruff/black and depends.runtime staged the pycvc-gl + numpy + torch closure)
# against the just-built package. A non-zero exit means the bundle is broken and
# must not ship. The FULL lint+test suite over the working tree runs in CI
# (.github/workflows/ci.yml), which installs this same closure via cvcpkg.
test:
script: test.sh

package:
files:
- lib/python3.11*/site-packages/grl_snam/
Expand Down
55 changes: 55 additions & 0 deletions cvcpkg/recipes/grl-snam/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# cvcpkg/recipes/grl-snam/test.sh — bundle self-test for the grl-snam package.
#
# Invoked by the packager after build.sh installs grl_snam / grl_snam_lab into
# $CVC_INSTALL_DIR. It runs under the build prefix, where:
# * depends.build staged the test/lint tools (pytest, ruff, black), and
# * depends.runtime staged the runtime closure (python311, pycvc-gl, numpy,
# torch) into $CVC_DEPS_PREFIX's own interpreter.
# Non-zero exit => the bundle is broken and must not ship.
#
# This is the cvcpkg-native counterpart to the GRL-SNAM CI test job. The v0.1.0
# sdist ships no tests/ dir, so we run `pytest -q` against a bundle smoke test
# that imports the just-built package (proving the runner works and the runtime
# closure resolves); when a future release carries its suite, we run that
# instead. The FULL lint+test over the working tree runs in CI, which installs
# this exact closure via `cvcpkg install-deps`.
set -euo pipefail

: "${CVC_INSTALL_DIR:?CVC_INSTALL_DIR must be set}"
: "${CVC_SOURCE_DIR:?CVC_SOURCE_DIR must be set}"
: "${CVC_DEPS_PREFIX:?CVC_DEPS_PREFIX must be set}"

PY="${CVC_DEPS_PREFIX}/bin/python3.11"
[ -x "${PY}" ] || { echo "FAIL: no python3.11 in deps prefix (${CVC_DEPS_PREFIX})"; exit 1; }

# Make the just-built grl_snam / grl_snam_lab importable alongside the deps
# already on the prefix interpreter's path (pycvc-gl, numpy, torch).
SP="$(find "${CVC_INSTALL_DIR}" -maxdepth 3 -type d -name site-packages -print -quit || true)"
export PYTHONPATH="${SP:-}${PYTHONPATH:+:${PYTHONPATH}}"

echo "-- grl-snam bundle self-test --"
echo "-- test/lint tools staged via depends.build --"
"${PY}" -m pytest --version
"${PY}" -m black --version
# ruff is a native binary in the deps prefix bin/ (no `python -m` needed).
"${CVC_DEPS_PREFIX}/bin/ruff" --version

echo "-- pytest -q --"
if [ -d "${CVC_SOURCE_DIR}/tests" ]; then
# A release that ships its suite: run it against the built package + deps.
"${PY}" -m pytest -q "${CVC_SOURCE_DIR}/tests"
else
# v0.1.0 sdist ships no tests/: exercise the runner against a bundle smoke
# test that imports the built package (proves closure + runner).
TMP="$(mktemp -d)"
trap 'rm -rf "${TMP}"' EXIT
cat > "${TMP}/test_bundle_smoke.py" <<'EOF'
def test_grl_snam_imports():
import grl_snam
assert isinstance(grl_snam.__version__, str)
EOF
"${PY}" -m pytest -q "${TMP}"
fi

echo "-- grl-snam recipe test passed --"
121 changes: 4 additions & 117 deletions grl_snam_lab/camera.py
Original file line number Diff line number Diff line change
@@ -1,117 +1,4 @@
"""A position-driven third-person chase camera.

The live GRL-SNAM planner feeds a STREAM of agent positions — the camera must not
assume the path's shape (a circle, a spline, anything). ``ChaseCamera`` estimates
the travel direction from a *smoothed velocity* of the incoming positions and
critically damps the camera pose toward a trailing target, so the view eases
smoothly even when the raw direction is noisy or snaps around.

It is pure math — no rendering, no volrover3, no assumption about the path. Feed
it ``update(pos, dt)`` and read back an ``(eye, target, up)`` to drive any camera
(in volrover3, write those into the ``volrover3.camera`` state tree; see
``examples/volrover_lab_animated.py``).

Robustness for real input:
* two-stage smoothing — a light EMA on position (kills jitter) feeds a heavier
EMA on the velocity estimate (a stable heading);
* frame-rate independent — every smoothing factor is ``1 - exp(-dt/tau)``, so
irregular tick spacing doesn't change the feel;
* stop-safe — below ``min_speed`` the last good heading is held, so the camera
doesn't spin when the agent pauses or pivots in place.
"""

from __future__ import annotations

import math


def _ema(prev, new, dt, tau):
"""Exponential moving average toward ``new`` with time-constant ``tau`` (s)."""
if prev is None:
return list(new)
a = 1.0 - math.exp(-dt / tau) if tau > 0.0 else 1.0
return [prev[i] + (new[i] - prev[i]) * a for i in range(len(new))]


class ChaseCamera:
"""Third-person follow camera driven by a stream of world positions.

Parameters (world units / seconds):
back trailing distance behind the agent
height camera height above the agent
look_ahead aim this far ahead of the agent along its heading (0 = at it)
look_up aim this far above the agent's base (so it isn't at the feet)
pos_tau position-smoothing time constant (small; de-jitter)
vel_tau velocity/heading-smoothing time constant (larger; stable heading)
cam_tau camera-pose damping time constant (the "ease" of the follow)
min_speed below this horizontal speed the heading is frozen (stop-safe)
up world up axis (Z-up scenes: (0, 0, 1))
"""

def __init__(
self,
back: float = 55.0,
height: float = 40.0,
look_ahead: float = 0.0,
look_up: float = 3.0,
pos_tau: float = 0.15,
vel_tau: float = 0.40,
cam_tau: float = 0.55,
min_speed: float = 0.05,
up=(0.0, 0.0, 1.0),
):
self.back = float(back)
self.height = float(height)
self.look_ahead = float(look_ahead)
self.look_up = float(look_up)
self.pos_tau = float(pos_tau)
self.vel_tau = float(vel_tau)
self.cam_tau = float(cam_tau)
self.min_speed = float(min_speed)
self.up = tuple(float(c) for c in up)
self.reset()

def reset(self):
"""Forget all history (snaps to the next update's target pose)."""
self._p = None # smoothed position
self._pprev = None
self._v = None # smoothed velocity (heading source)
self._head = None # last good horizontal unit heading (held while stopped)
self._eye = None
self._tgt = None

def update(self, pos, dt: float):
"""Feed the agent's current world position ``(x, y, z)`` and the seconds
since the last update; returns ``(eye, target, up)`` for the camera."""
pos = [float(pos[0]), float(pos[1]), float(pos[2])]
dt = max(float(dt), 1e-4)

# 1) light position smoothing (de-jitter without noticeable lag)
self._p = _ema(self._p, pos, dt, self.pos_tau)

# 2) velocity from the smoothed-position delta, then smooth the velocity
if self._pprev is not None:
raw_v = [(self._p[i] - self._pprev[i]) / dt for i in range(3)]
self._v = _ema(self._v, raw_v, dt, self.vel_tau)
self._pprev = list(self._p)

# 3) heading = horizontal unit velocity; hold the last one if too slow
if self._v is not None:
speed = math.hypot(self._v[0], self._v[1])
if speed >= self.min_speed:
self._head = (self._v[0] / speed, self._v[1] / speed)
hx, hy = self._head if self._head is not None else (1.0, 0.0)

# 4) target pose: trail behind + above; look at (a touch ahead of) the agent
p = self._p
target_eye = [p[0] - hx * self.back, p[1] - hy * self.back, p[2] + self.height]
target_look = [
p[0] + hx * self.look_ahead,
p[1] + hy * self.look_ahead,
p[2] + self.look_up,
]

# 5) critically damp the camera toward the target pose (smooth follow)
self._eye = _ema(self._eye, target_eye, dt, self.cam_tau)
self._tgt = _ema(self._tgt, target_look, dt, self.cam_tau)
return tuple(self._eye), tuple(self._tgt), self.up
"""Chase camera — moved to :mod:`pycvc_gl.camera` (a generic scene utility that
ships with the pycvc_gl bindings). Re-exported here for backward compatibility."""
from pycvc_gl.camera import * # noqa: F401,F403
from pycvc_gl.camera import ChaseCamera # noqa: F401
Loading
Loading