diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5475969..1ab723a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Please try to the best of your abilities to: ## Adding a new lifter class -You can start with the [ExampleLifter](../popr/examples/ExampleLifter.py) skeleton, and feel free to add more functionalities depending on the nature of the problem. You can also consider adding a new base class similar to [RobustPoseLifter](../popr/base_lifters/RobustPoseLifter.py) or [StereoLifter](../popr/base_lifters/StereoLifter.py) if you want to create multiple new lifters that all share similar functionalities. +You can start with the [ExampleLifter](../popcor/examples/ExampleLifter.py) skeleton, and feel free to add more functionalities depending on the nature of the problem. You can also consider adding a new base class similar to [RobustPoseLifter](../popcor/base_lifters/RobustPoseLifter.py) or [StereoLifter](../popcor/base_lifters/StereoLifter.py) if you want to create multiple new lifters that all share similar functionalities. ## Adding new functionalities @@ -37,7 +37,7 @@ to make sure there are no errors. There is also the possibility to just use lite This has already been done -- just keeping track of the process here to make it is easy to redo this in the future. TAken from [here](https://docs.mosek.com/11.0/faq.pdf), page 11. -1. Go to Settings -> Security -> Secrets and variables -> Actions ([direct link](https://github.com/duembgen/popr/settings/secrets/actions)) +1. Go to Settings -> Security -> Secrets and variables -> Actions ([direct link](https://github.com/duembgen/popcor/settings/secrets/actions)) 2. Create secret called MSK_LICENSE with content ``` diff --git a/README.md b/README.md index 54265de..f5fbd7d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ [![Python Package using Conda](https://github.com/duembgen/popr/actions/workflows/python-package-conda.yml/badge.svg)](https://github.com/duembgen/popr/actions/workflows/python-package-conda.yml) [![Documentation build](https://github.com/duembgen/popr/actions/workflows/documentation.yml/badge.svg)](https://duembgen.github.io/popcor) -[![Documentation deploy](https://github.com/duembgen/popcor/actions/workflows/static.yml/badge.svg)](https://duembgen.github.io/popcor) ![](./docs/source/_static/overview.png) diff --git a/docs/source/_static/overview.svg b/docs/source/_static/overview.svg index 453213c..8d71623 100644 --- a/docs/source/_static/overview.svg +++ b/docs/source/_static/overview.svg @@ -61,9 +61,9 @@ id="page14" margin="0" bleed="0" - inkscape:export-filename="poster-new.pdf" - inkscape:export-xdpi="96" - inkscape:export-ydpi="96" /> self.MIN_DIST + ): + samples[i, :] = sample + elif j == MAX_TRIALS - 1: + print( + f"Warning: did not find valid sample in {MAX_TRIALS} trials. Using last sample." + ) + samples[i, :] = sample + return samples.flatten("C") def get_residuals(self, t, y, squared=True, ad=False): positions = t.reshape((-1, self.d)) @@ -203,7 +233,7 @@ def simulate_y(self, noise: float | None = None, squared: bool = True): assert self.landmarks is not None # N x K matrix if noise is None: - noise = NOISE + noise = self.NOISE positions = self.theta.reshape(self.n_positions, -1) y_gt = np.linalg.norm( self.landmarks[None, :, :] - positions[:, None, :], axis=2 @@ -246,10 +276,12 @@ def get_theta(self, x): return x[: self.n_positions * self.d] def get_error(self, theta_hat, error_type="rmse", *args, **kwargs): + assert np.ndim(theta_hat) <= 1 + theta_gt = self.theta if np.ndim(self.theta) <= 1 else self.theta.flatten() if error_type == "rmse": - return np.sqrt(np.mean((self.theta - theta_hat) ** 2)) + return np.sqrt(np.mean((theta_gt - theta_hat) ** 2)) elif error_type == "mse": - return np.mean((self.theta - theta_hat) ** 2) + return np.mean((theta_gt - theta_hat) ** 2) else: raise ValueError(f"Unkwnon error_type {error_type}") @@ -322,15 +354,22 @@ def fun(x): info["cost"] = cost return that, info, cost - def plot(self, y=None, xlims=[0, 2], ylims=[0, 2], ax=None): + def plot(self, y=None, xlims=[0, 2], ylims=[0, 2], ax=None, estimates={}): if ax is None: fig, ax = plt.subplots() fig.set_size_inches(5, 5) else: fig = plt.gcf() + if np.ndim(self.theta) < 2: + theta_gt = self.theta.reshape((self.n_positions, self.d)) + ax.scatter(*self.landmarks[:, :2].T, color="k", marker="+", label="landmarks") - ax.scatter(*self.theta[:, :2].T, color="C0", marker="o") + ax.scatter(*theta_gt[:, :2].T, color="C0", marker="o") + for label, theta_est in estimates.items(): + if np.ndim(theta_est) < 2: + theta_est = theta_est.reshape((self.n_positions, self.d)) + ax.scatter(*theta_est[:, :2].T, marker="x", label=label) im = None if y is not None: @@ -353,10 +392,6 @@ def plot(self, y=None, xlims=[0, 2], ylims=[0, 2], ax=None): ax.set_aspect("equal") return fig, ax, im - def get_valid_samples(self, n_samples): - samples = [self.sample_theta() for _ in range(n_samples)] - return np.vstack(samples) - @property @abstractmethod def size_z(self) -> int: diff --git a/popcor/base_lifters/robust_pose_lifter.py b/popcor/base_lifters/robust_pose_lifter.py index dbce8b3..6ae0c5c 100644 --- a/popcor/base_lifters/robust_pose_lifter.py +++ b/popcor/base_lifters/robust_pose_lifter.py @@ -271,6 +271,7 @@ def get_vec_around_gt(self, delta: float = 0): return theta_noisy def get_cost(self, theta, y): + assert y is not None if self.robust: x = theta[: -self.n_landmarks] w = theta[-self.n_landmarks :] @@ -297,6 +298,7 @@ def local_solver( method=METHOD, solver_kwargs=SOLVER_KWARGS, ): + assert y is not None import pymanopt from pymanopt.manifolds import Euclidean, Product, SpecialOrthogonalGroup diff --git a/popcor/base_lifters/state_lifter.py b/popcor/base_lifters/state_lifter.py index 6360ab6..df42d6a 100644 --- a/popcor/base_lifters/state_lifter.py +++ b/popcor/base_lifters/state_lifter.py @@ -160,7 +160,13 @@ def get_cost(self, theta, y: np.ndarray | None = None) -> float: Q = self.get_Q() return float(x.T @ Q @ x) - def local_solver(self, t0, y: np.ndarray | None = None, *args, **kwargs): + def local_solver( + self, + t0, + y: np.ndarray | list | None = None, + *args, + **kwargs, + ): """ Default local solver that uses IPOPT to solve the QCQP problem defined by Q and the constraints matrices. Consider overwriting this for more efficient solvers. @@ -168,7 +174,8 @@ def local_solver(self, t0, y: np.ndarray | None = None, *args, **kwargs): print( "Warning: using default local_solver, which may be less efficient than a custom one." ) - print("Ignoring args and kwargs:", args, kwargs) + if len(args): + print(f"Warning: ignore args {args}") from cert_tools.sdp_solvers import solve_low_rank_sdp if y is not None: @@ -182,10 +189,15 @@ def local_solver(self, t0, y: np.ndarray | None = None, *args, **kwargs): "Inequality constraints are not currently considered by default solver. Must implement your own." ) + if method := kwargs.pop("method", None) is not None: + print( + f"Warning: ignoreing method argument {method} in local_solver, using default (IPOPT)." + ) + Constraints = self.get_A_b_list(A_list=self.get_A_known()) x0 = self.get_x(theta=t0) X, info = solve_low_rank_sdp( - Q, Constraints=Constraints, rank=1, verbose=True, x_cand=x0 + Q, Constraints=Constraints, rank=1, x_cand=x0, **kwargs ) # TODO(FD) identify when the solve is not successful. info["success"] = True @@ -228,3 +240,7 @@ def get_theta(self, x): "Please make sure that you use get_theta as intended." ) return x.flatten()[: self.d] + + def get_valid_samples(self, n_samples): + samples = [self.sample_theta().flatten() for _ in range(n_samples)] + return np.vstack(samples) diff --git a/popcor/examples/ro_nsq_lifter.py b/popcor/examples/ro_nsq_lifter.py index 65f44fe..3818220 100644 --- a/popcor/examples/ro_nsq_lifter.py +++ b/popcor/examples/ro_nsq_lifter.py @@ -4,10 +4,6 @@ from popcor.base_lifters import RangeOnlyLifter -NOISE = 1e-2 # std deviation of distance noise - -NORMALIZE = True - class RangeOnlyNsqLifter(RangeOnlyLifter): """Range-only localization in 2D or 3D. @@ -45,6 +41,8 @@ class RangeOnlyNsqLifter(RangeOnlyLifter): } MONOMIAL_DEGREE = 1 + SCALE = 1.0 + @staticmethod def create_good(n_positions, n_landmarks, d=2): landmarks, theta = RangeOnlyLifter.create_good(n_positions, n_landmarks, d) @@ -244,32 +242,10 @@ def size_z(self) -> int: def get_valid_samples( self, n_samples, - max_trials=3, - min_dist=1e-2, - radius=1.0, - center=None, - vectorized=True, ): - # quick and dirty implementation to make sure we don't sample too close - # from a landmark (otherwise length is zero and the normal vector will have nans) - if center is None: - center = np.ones(self.landmarks.shape[1]) - samples = [] - for i in range(n_samples): - for j in range(max_trials): - sample = ( - (np.random.rand(self.landmarks.shape[1]) - 0.5) * 2 * radius - ) + center # between 0 and 1 - if np.all( - np.linalg.norm(sample[None, :] - self.landmarks, axis=1) > min_dist - ): - samples.append(sample) - break - if j == max_trials - 1: - print(f"Warning: did not find valid sample in {max_trials} trials") - - samples = np.vstack(samples) + samples = super().get_valid_samples(n_samples) + # TODO(FD): maybe this should be moved to theta. normals = self.landmarks[None, :, :] - samples[:, None, :] normals /= np.linalg.norm(normals, axis=2)[:, :, None] return np.hstack([samples, normals.reshape(normals.shape[0], -1)]) diff --git a/popcor/examples/rotation_lifter.py b/popcor/examples/rotation_lifter.py index c6ac72b..9df3d14 100644 --- a/popcor/examples/rotation_lifter.py +++ b/popcor/examples/rotation_lifter.py @@ -44,7 +44,7 @@ def sample_theta(self): C = R.from_euler("z", angle).as_matrix()[:2, :2] elif self.d == 3: C = R.random().as_matrix() - return C.flatten("C") + return C def get_x(self, theta=None, parameters=None, var_subset=None) -> np.ndarray: """Get the lifted vector x given theta and parameters.""" @@ -60,14 +60,15 @@ def get_x(self, theta=None, parameters=None, var_subset=None) -> np.ndarray: if key == self.HOM: x_data.append(1.0) elif key == "c": - x_data += list(theta) + x_data += list(theta.flatten("C")) dim_x = self.get_dim_x(var_subset=var_subset) assert len(x_data) == dim_x return np.array(x_data) def get_theta(self, x: np.ndarray) -> np.ndarray: assert np.ndim(x) == 1 - return x[: self.d**2] + C_flat = x[: self.d**2] + return C_flat.reshape((self.d, self.d)) def simulate_y(self, noise: float | None = None) -> list: if noise is None: @@ -247,13 +248,30 @@ def get_A_known(self, var_dict=None, output_poly=False, add_redundant=False): self.test_and_add(A_list, Ai, output_poly=output_poly) return A_list - def __repr__(self): - return f"rotation_lifter{self.d}d" + def plot(self, estimates={}): + import itertools + + import matplotlib.pyplot as plt + + from popcor.utils.plotting_tools import plot_frame + fig, ax = plt.subplots() + plot_frame(ax=ax, theta=self.theta, label="gt", ls="-", scale=0.5, marker="") -if __name__ == "__main__": + linestyles = itertools.cycle(["--", "-.", ":"]) + for label, theta in estimates.items(): + plot_frame( + ax=ax, + theta=theta, + label=label, + ls=next(linestyles), + scale=1.0, + marker="", + ) - # adding this here because on save vscode sometimes adds duplicate lines - pass - # adding this here because on save vscode sometimes adds duplicate lines - pass + ax.set_aspect("equal") + ax.legend() + return fig, ax + + def __repr__(self): + return f"rotation_lifter{self.d}d" diff --git a/popcor/utils/plotting_tools.py b/popcor/utils/plotting_tools.py index 6890bc1..4adc893 100644 --- a/popcor/utils/plotting_tools.py +++ b/popcor/utils/plotting_tools.py @@ -105,7 +105,7 @@ def savefig(fig, name, verbose=True): def plot_frame( ax, - theta=None, + theta, color="k", marker="+", label=None, @@ -115,12 +115,17 @@ def plot_frame( d=3, **kwargs, ): - try: - C_cw, r_wc_c = get_C_r_from_theta(theta, d=d) - r_wc_w = -C_cw.T @ r_wc_c # r_wc_w - except Exception as e: - C_cw = None - r_wc_w = theta + if np.ndim(theta) == 2: + # used by rotation averaging + C_cw = theta + r_wc_w = np.zeros((theta.shape[0])) + else: + try: + C_cw, r_wc_c = get_C_r_from_theta(theta, d=d) + r_wc_w = -C_cw.T @ r_wc_c # r_wc_w + except Exception as e: + C_cw = None + r_wc_w = theta assert r_wc_w is not None @@ -139,8 +144,18 @@ def plot_frame( *r_wc_w[:2].T, color=color, marker=marker, + label=None, + zorder=1, + **kwargs, + ) + ax.plot( + [], + [], + marker=marker, label=label, zorder=1, + ls=ls, + color="k", **kwargs, ) return r_wc_w, C_cw diff --git a/popcor/utils/test_tools.py b/popcor/utils/test_tools.py index c2ddfe5..0c80508 100644 --- a/popcor/utils/test_tools.py +++ b/popcor/utils/test_tools.py @@ -41,11 +41,11 @@ ), # not tight ( RangeOnlySqLifter, - dict(n_positions=n_positions, n_landmarks=n_landmarks, d=d, level="no"), + dict(n_positions=n_positions, n_landmarks=4, d=d, level="no"), ), # ok ( RangeOnlySqLifter, - dict(n_positions=n_positions, n_landmarks=n_landmarks, d=d, level="quad"), + dict(n_positions=n_positions, n_landmarks=4, d=d, level="quad"), ), # ok (Stereo1DLifter, dict(n_landmarks=n_landmarks)), # not tight (Stereo1DLifter, dict(n_landmarks=n_landmarks, param_level="p")), # skip diff --git a/scripts/rotation_averaging.ipynb b/scripts/rotation_averaging.ipynb new file mode 100644 index 0000000..dcd99d1 --- /dev/null +++ b/scripts/rotation_averaging.ipynb @@ -0,0 +1,144 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b56347d1", + "metadata": {}, + "source": [ + "# Rotation averaging example\n", + "\n", + "This example script demonstrates how to solve rotation averaging problems using AutoTight." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed5be9cc-ad44-421a-b852-d44fc37c42ed", + "metadata": {}, + "outputs": [], + "source": [ + "%reload_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "id": "e258188b", + "metadata": {}, + "source": [ + "## Setup a new problem and solve it locally\n", + "\n", + "We initialize either from ground truth or from randomly sampled rotations " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3c8c3215", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from popcor.examples import RotationLifter\n", + "\n", + "np.random.seed(2)\n", + "lifter = RotationLifter(d=3, n_meas=3)\n", + "\n", + "y = lifter.simulate_y(noise=0.2)\n", + "\n", + "theta_gt, *_ = lifter.local_solver(lifter.theta, y, verbose=False)\n", + "estimates = {\"init gt\": theta_gt}\n", + "for i in range(10):\n", + " theta_init = lifter.sample_theta()\n", + " theta_i, *_ = lifter.local_solver(theta_init, y, verbose=False)\n", + " estimates[f\"init random {i}\"] = theta_i\n", + "\n", + "fig, ax = lifter.plot(estimates=estimates)\n", + "ax.legend([])\n", + "plt.show(block=False)" + ] + }, + { + "cell_type": "markdown", + "id": "da3c8b79-8d8e-497c-9761-21b678450217", + "metadata": {}, + "source": [ + "## Solve the rotation averaging with an SDP" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64bbdf6b", + "metadata": {}, + "outputs": [], + "source": [ + "from popcor.utils.plotting_tools import plot_matrix\n", + "\n", + "Q = lifter.get_Q_from_y(y=y)\n", + "A_known = lifter.get_A_known()\n", + "constraints = lifter.get_A_b_list(lifter.get_A_known())\n", + "\n", + "fig, axs = plt.subplots(1, len(A_known) + 1)\n", + "fig.set_size_inches(3*(len(A_known) + 1), 3)\n", + "for i in range(len(A_known)):\n", + " plot_matrix(A_known[i].toarray(), ax=axs[i], title=f\"A{i} \", colorbar=False)\n", + "fig = plot_matrix(Q.toarray(), ax=axs[i+1], title=\"Q\", colorbar=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be73dc95", + "metadata": {}, + "outputs": [], + "source": [ + "from cert_tools.sdp_solvers import solve_sdp\n", + "from cert_tools.linalg_tools import rank_project\n", + "\n", + "X, info = solve_sdp(Q, constraints, verbose=False)\n", + "\n", + "x, info_rank = rank_project(X, p=1)\n", + "print(f\"EVR: {info_rank['EVR']:.2e}\")\n", + "\n", + "theta_opt = lifter.get_theta(x.flatten()[1:])\n", + "\n", + "estimates = {\"init gt\": theta_gt, \"SDP\": theta_opt}\n", + "fig, ax = lifter.plot(estimates=estimates)" + ] + }, + { + "cell_type": "markdown", + "id": "b7c84433-d923-403b-92a1-bd5ab3e0ce92", + "metadata": {}, + "source": [ + "## Conclusion\n", + "\n", + "This problem is too easy! No redundant measurements are required for tightness. " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.17" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 0cda1bb..19ee91c 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -193,7 +193,7 @@ def test_solvers(n_seeds=1, noise=0.0): if len(theta_hat) == len(theta_gt): np.testing.assert_allclose(theta_hat, theta_gt) else: - # theta_gt = lifter.get_vec_around_gt(delta=0) + theta_gt = lifter.get_vec_around_gt(delta=0) np.testing.assert_allclose(theta_hat, theta_gt) else: @@ -245,13 +245,18 @@ def test_solvers(n_seeds=1, noise=0.0): print( f"Found solution for {lifter} is not ground truth in zero-noise! is the problem well-conditioned?" ) + fig, ax, im = lifter.plot(estimates={"estimate": theta_hat}) + import matplotlib.pylab as plt + + plt.show(block=False) + ax.legend() try: mineig_hess_hat = np.linalg.eigvalsh( - lifter.get_hess(theta_hat, y) + lifter.get_hess(theta_hat, y).toarray() )[0] mineig_hess_gt = np.linalg.eigvalsh( - lifter.get_hess(theta_gt, y) + lifter.get_hess(theta_gt, y).toarray() )[0] print( f"minimum eigenvalue at gt: {mineig_hess_gt:.1e} and at estimate: {mineig_hess_hat:.1e}" diff --git a/tests/test_state_lifter.py b/tests/test_state_lifter.py index 8af7f35..b005d33 100644 --- a/tests/test_state_lifter.py +++ b/tests/test_state_lifter.py @@ -4,7 +4,11 @@ from popcor.base_lifters import StateLifter, StereoLifter from popcor.examples import Stereo2DLifter, Stereo3DLifter -from popcor.utils.common import get_vec, ravel_multi_index_triu, unravel_multi_index_triu +from popcor.utils.common import ( + get_vec, + ravel_multi_index_triu, + unravel_multi_index_triu, +) from popcor.utils.test_tools import all_lifters, constraints_test_with_tol