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
33 changes: 33 additions & 0 deletions .github/workflows/build-and-validate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Build and Validate

on:
pull_request:
branches: [main, development]

jobs:
build-and-validate:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image (${{ matrix.platform }})
run: docker build --platform ${{ matrix.platform }} -t gtsam_docker:latest .

- name: Validate image (PlanarSLAM example)
run: |
chmod +x ./scripts/validate_container.sh
./scripts/validate_container.sh gtsam_docker:latest /examples/PlanarSLAMExample.py
43 changes: 36 additions & 7 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ jobs:
build:
strategy:
matrix:
platform: [ "linux/amd64" ]
# Use GitHub-hosted runner for amd64 and the arm64 partner runner for arm64
platform: [ "linux/amd64", "linux/arm64" ]
# Use GitHub-hosted runner for amd64; arm64 uses partner runner (ensure ubuntu-24.04-arm is enabled for the repo/org)
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
permissions:
contents: read
Expand Down Expand Up @@ -62,8 +62,37 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

manifest:
validate:
needs: build
strategy:
fail-fast: false
matrix:
arch: [ amd64, arm64 ]
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
permissions:
contents: read
packages: read
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Pull and validate image (${{ matrix.arch }})
run: |
LOWER_IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
docker pull ${{ env.REGISTRY }}/${LOWER_IMAGE_NAME}:${{ inputs.version }}-${{ matrix.arch }}
docker tag ${{ env.REGISTRY }}/${LOWER_IMAGE_NAME}:${{ inputs.version }}-${{ matrix.arch }} gtsam_docker:latest
chmod +x ./scripts/validate_container.sh
./scripts/validate_container.sh gtsam_docker:latest /examples/PlanarSLAMExample.py

manifest:
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -78,18 +107,18 @@ jobs:

- name: Create multi-arch manifest for latest
run: |
# Convert IMAGE_NAME to lowercase
LOWER_IMAGE_NAME=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')
docker manifest create $REGISTRY/${LOWER_IMAGE_NAME}:latest \
$REGISTRY/${LOWER_IMAGE_NAME}:latest-amd64
$REGISTRY/${LOWER_IMAGE_NAME}:latest-amd64 \
$REGISTRY/${LOWER_IMAGE_NAME}:latest-arm64
docker manifest push $REGISTRY/${LOWER_IMAGE_NAME}:latest

- name: Create multi-arch manifest for version tag
run: |
# Convert IMAGE_NAME to lowercase
LOWER_IMAGE_NAME=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')
docker manifest create $REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }} \
$REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }}-amd64
$REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }}-amd64 \
$REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }}-arm64
docker manifest push $REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }}

release:
Expand Down
125 changes: 50 additions & 75 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
FROM debian:bullseye as dependencies
# Default build produces the "runtime" stage (slim). Use --target gtsam for a dev image with build tools and shell.
FROM debian:trixie-20260112 AS dependencies
ARG PYTHON_VERSION=3.11.2

# Disable GUI prompts
ENV DEBIAN_FRONTEND noninteractive
ENV DEBIAN_FRONTEND=noninteractive


RUN rm /var/lib/dpkg/info/libc-bin.*
RUN apt-get clean && apt-get update
RUN apt-get -y install libc-bin

# Install required build dependencies
RUN apt-get update && apt-get install -y \
# Install required build dependencies (single update for better layer caching)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
build-essential \
wget \
libssl-dev \
Expand All @@ -33,56 +30,9 @@ RUN apt-get update && apt-get install -y \
libmpc-dev \
libmpfr-dev \
libgmp-dev \
make \
&& rm -rf /var/lib/apt/lists/*


# Download and build GCC 13.4
RUN wget https://ftp.gnu.org/gnu/gcc/gcc-13.4.0/gcc-13.4.0.tar.gz && \
tar -xzf gcc-13.4.0.tar.gz && \
cd gcc-13.4.0 && \
./contrib/download_prerequisites && \
mkdir build && \
cd build && \
../configure --prefix=/usr/local/gcc-13.4.0 \
--enable-languages=c,c++ \
--disable-multilib \
--disable-bootstrap \
--enable-checking=release && \
make -j$(nproc) && \
make install && \
cd ../.. && \
rm -rf gcc-13.4.0 gcc-13.4.0.tar.gz

# Set up GCC 13.4 as the default compiler
ENV PATH="/usr/local/gcc-13.4.0/bin:${PATH}"
ENV LD_LIBRARY_PATH="/usr/local/gcc-13.4.0/lib64:${LD_LIBRARY_PATH}"
ENV CC="/usr/local/gcc-13.4.0/bin/gcc"
ENV CXX="/usr/local/gcc-13.4.0/bin/g++"

# Create symlinks for easier access
RUN ln -sf /usr/local/gcc-13.4.0/bin/gcc /usr/local/bin/gcc && \
ln -sf /usr/local/gcc-13.4.0/bin/g++ /usr/local/bin/g++

# Install Make 4.4.1
RUN wget https://ftp.gnu.org/gnu/make/make-4.4.1.tar.gz && \
tar -xzf make-4.4.1.tar.gz && \
cd make-4.4.1 && \
./configure --prefix=/usr/local && \
make -j$(nproc) && \
make install && \
cd .. && \
rm -rf make-4.4.1 make-4.4.1.tar.gz

# Install CMake 4.0.3
RUN wget https://github.com/Kitware/CMake/releases/download/v4.0.3/cmake-4.0.3.tar.gz && \
tar -xzf cmake-4.0.3.tar.gz && \
cd cmake-4.0.3 && \
./bootstrap --prefix=/usr/local && \
make -j$(nproc) && \
make install && \
cd .. && \
rm -rf cmake-4.0.3 cmake-4.0.3.tar.gz

# Set working directory
WORKDIR /usr/src

Expand All @@ -100,33 +50,33 @@ RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VER
# Ensure /usr/local/bin is in the PATH
ENV PATH="/usr/local/bin:${PATH}"

RUN python3 -m pip install --upgrade pip
RUN python3 -m pip install --no-cache-dir --upgrade pip

# Use git to clone gtsam and specific GTSAM version
FROM alpine/git:2.52.0 as gtsam-clone
FROM alpine/git:2.52.0 AS gtsam-clone

ARG GTSAM_VERSION=4.2.0
WORKDIR /usr/src/

# Clone GTSAM and checkout to given GTSAM_VERSION tag
RUN git clone --no-checkout https://github.com/borglab/gtsam.git && \
cd gtsam && \
git fetch origin tag ${GTSAM_VERSION} && \
git checkout ${GTSAM_VERSION}
# Shallow clone specific tag for smaller, faster fetch
RUN git clone --depth 1 --branch ${GTSAM_VERSION} https://github.com/borglab/gtsam.git

# Create new stage called gtsam for GTSAM building
FROM dependencies as gtsam
FROM dependencies AS gtsam

ARG PYTHON_VERSION=3.11.2

# Needed to link with GTSAM (ENV works in non-interactive shells; .bashrc does not)
ENV LD_LIBRARY_PATH=/usr/local/lib

# Move gtsam data
COPY --from=gtsam-clone /usr/src/gtsam /usr/src/gtsam

WORKDIR /usr/src/gtsam/build

# Needed to link with GTSAM
RUN echo "export LD_LIBRARY_PATH=/usr/local/lib:\$LD_LIBRARY_PATH" >> /root/.bashrc

# Install python wrapper requirements
RUN python3 -m pip install -U -r /usr/src/gtsam/python/requirements.txt
# Install python wrapper requirements, then pin numpy for GTSAM ABI compatibility
RUN python3 -m pip install --no-cache-dir -U -r /usr/src/gtsam/python/requirements.txt && \
python3 -m pip install --no-cache-dir "numpy==1.26.4"

# Run cmake
RUN cmake \
Expand All @@ -141,14 +91,39 @@ RUN cmake \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
..

# Make install and clean up
# Build, install, strip binaries, and clean in one layer to reduce image size
RUN make -j$(nproc) install && \
make python-install && \
make clean
#find /usr/local -type f \( -name "*.so" -o -name "*.so.*" \) -exec strip --strip-unneeded {} \; 2>/dev/null || true && \
#find /usr/local/bin /usr/local/lib -executable -type f -exec strip --strip-unneeded {} \; 2>/dev/null || true && \
make clean && \
ldconfig

# Final cleanup (dependencies stage already cleared apt lists)
RUN rm -rf /tmp/* /var/tmp/*

# -----------------------------------------------------------------------------
# Slim runtime stage: copy only installed artifacts, no build tools or source
# -----------------------------------------------------------------------------
FROM debian:trixie-slim AS runtime

ENV DEBIAN_FRONTEND=noninteractive
ENV PATH="/usr/local/bin:${PATH}"
ENV LD_LIBRARY_PATH=/usr/local/lib

# Runtime libs only. Python binary (ldd python3.11) needs only libc/libm/libpython; GTSAM needs Boost + TBB (see scripts/audit-runtime-deps.sh).
# Add back libssl3t64 libbz2-1.0 libreadline8t64 libsqlite3-0 libffi8 zlib1g libncursesw6 if you import ssl/sqlite3/readline/etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libtbb12 \
libtbbmalloc2 \
libboost-serialization1.83.0 \
libboost-filesystem1.83.0 \
libboost-timer1.83.0 \
&& rm -rf /var/lib/apt/lists/*

RUN apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
COPY --from=gtsam /usr/local /usr/local

RUN ldconfig

CMD ["bash"]
CMD ["python3"]
93 changes: 93 additions & 0 deletions examples/PlanarSLAMExample.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""
GTSAM Copyright 2010-2018, Georgia Tech Research Corporation,
Atlanta, Georgia 30332-0415
All Rights Reserved
Authors: Frank Dellaert, et al. (see THANKS for the full author list)
See LICENSE for the license information

Simple robotics example using odometry measurements and bearing-range (laser) measurements.
From borglab/gtsam python/gtsam/examples/PlanarSLAMExample.py (GTSAM 4.2.0).

Run inside container: python3 /examples/PlanarSLAMExample.py
"""
# pylint: disable=invalid-name, E1101

from __future__ import print_function

import sys
import gtsam
import numpy as np
from gtsam.symbol_shorthand import L, X

# Create noise models
PRIOR_NOISE = gtsam.noiseModel.Diagonal.Sigmas(np.array([0.3, 0.3, 0.1]))
ODOMETRY_NOISE = gtsam.noiseModel.Diagonal.Sigmas(np.array([0.2, 0.2, 0.1]))
MEASUREMENT_NOISE = gtsam.noiseModel.Diagonal.Sigmas(np.array([0.1, 0.2]))


def main():
"""Main runner."""
# Create an empty nonlinear factor graph
graph = gtsam.NonlinearFactorGraph()

# Create the keys corresponding to unknown variables in the factor graph
x1, x2, x3 = X(1), X(2), X(3)
l1, l2 = L(4), L(5)

# Add a prior on pose X1 at the origin
graph.add(
gtsam.PriorFactorPose2(x1, gtsam.Pose2(0.0, 0.0, 0.0), PRIOR_NOISE)
)

# Add odometry factors between X1,X2 and X2,X3
graph.add(
gtsam.BetweenFactorPose2(x1, x2, gtsam.Pose2(2.0, 0.0, 0.0), ODOMETRY_NOISE)
)
graph.add(
gtsam.BetweenFactorPose2(x2, x3, gtsam.Pose2(2.0, 0.0, 0.0), ODOMETRY_NOISE)
)

# Add Range-Bearing measurements to two different landmarks L1 and L2
graph.add(
gtsam.BearingRangeFactor2D(
x1, l1, gtsam.Rot2.fromDegrees(45), np.sqrt(4.0 + 4.0), MEASUREMENT_NOISE
)
)
graph.add(
gtsam.BearingRangeFactor2D(x2, l1, gtsam.Rot2.fromDegrees(90), 2.0, MEASUREMENT_NOISE)
)
graph.add(
gtsam.BearingRangeFactor2D(x3, l2, gtsam.Rot2.fromDegrees(90), 2.0, MEASUREMENT_NOISE)
)

print("Factor Graph:\n{}".format(graph))

# Create (deliberately inaccurate) initial estimate
initial_estimate = gtsam.Values()
initial_estimate.insert(x1, gtsam.Pose2(-0.25, 0.20, 0.15))
initial_estimate.insert(x2, gtsam.Pose2(2.30, 0.10, -0.20))
initial_estimate.insert(x3, gtsam.Pose2(4.10, 0.10, 0.10))
initial_estimate.insert(l1, gtsam.Point2(1.80, 2.10))
initial_estimate.insert(l2, gtsam.Point2(4.10, 1.80))

print("Initial Estimate:\n{}".format(initial_estimate))

# Optimize using Levenberg-Marquardt
params = gtsam.LevenbergMarquardtParams()
optimizer = gtsam.LevenbergMarquardtOptimizer(graph, initial_estimate, params)
result = optimizer.optimize()
print("\nFinal Result:\n{}".format(result))

# Calculate and print marginal covariances
marginals = gtsam.Marginals(graph, result)
for (key, label) in [(x1, "X1"), (x2, "X2"), (x3, "X3"), (l1, "L1"), (l2, "L2")]:
print("{} covariance:\n{}\n".format(label, marginals.marginalCovariance(key)))

# Validation: expect result size and non-NaN covariances
assert result.size() == 5, "Expected 5 values in result"
_ = marginals.marginalCovariance(x1) # will raise if invalid
print("VALIDATION OK")
return 0

if __name__ == "__main__":
sys.exit(main())
Loading
Loading