惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Security Affairs
S
Schneier on Security
T
Tenable Blog
G
GRAHAM CLULEY
Latest news
Latest news
D
Darknet – Hacking Tools, Hacker News & Cyber Security
A
Arctic Wolf
I
Intezer
Cyberwarzone
Cyberwarzone
T
The Exploit Database - CXSecurity.com
T
Tailwind CSS Blog
K
Kaspersky official blog
Blog — PlanetScale
Blog — PlanetScale
C
Cyber Attacks, Cyber Crime and Cyber Security
T
Threat Research - Cisco Blogs
爱范儿
爱范儿
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
博客园 - 叶小钗
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Recent Commits to openclaw:main
Recent Commits to openclaw:main
P
Palo Alto Networks Blog
WordPress大学
WordPress大学
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 司徒正美
The Cloudflare Blog
Help Net Security
Help Net Security
罗磊的独立博客
博客园 - 聂微东
Jina AI
Jina AI
Project Zero
Project Zero
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
L
LINUX DO - 最新话题
V
V2EX
人人都是产品经理
人人都是产品经理
美团技术团队
博客园 - 【当耐特】
Spread Privacy
Spread Privacy
J
Java Code Geeks
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Security Latest
Security Latest
The Last Watchdog
The Last Watchdog
Stack Overflow Blog
Stack Overflow Blog
雷峰网
雷峰网
S
Securelist
Forbes - Security
Forbes - Security
博客园 - 三生石上(FineUI控件)
Microsoft Azure Blog
Microsoft Azure Blog
P
Privacy International News Feed
宝玉的分享
宝玉的分享
C
CERT Recently Published Vulnerability Notes

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 分享一下笔者的 Mac 装机必备软件 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解 【002】HTTPS 粗解:证书、TLS 握手与对后端配置的影响 Hermes Agent 一周暴涨五万 Star,但我劝你别急着追 明明连接的是Redis的DB0,为什么能查到DB3的数据? 【从0到1构建一个ClaudeAgent】协作-Agent团队 熟悉电子元器件之后,电子小白下一步该怎么走? MAF快速入门(23)通过C#类定义Skills .NET 高级开发 | 手写一个对象映射框架 FastAPI数据库ORM怎么选?我肝了三个Demo后,终于不再纠结了 mysqldump 参数拾遗:在遗忘与铭记之间 C# .NET 周刊|2026年3月5期 Claude code入门 - 陈彦斌 一文学习入门 ThingsBoard 开源物联网平台 GitHub 热门项目 | 2026年04月16日 如何为GIT设置全局勾子,为每次提交追加信息 Number.isFinite和isFinite与isNaN()和Number.isNaN的区别 PortSwigger SQL注入LAB2 推荐一个测试人必备的Skills,从功能到性能全搞定(附详细实操和安装下载方式) 筑基期:掌握Odoo基础核心知识点02(Odoo XML 开发方式详解) GLM模型这么火,咱们用vllm也咧一个呗! 深入理解 AbortController:从底层原理到跨语言设计哲学 字符串学习笔记 多租户系统框架的基础模块设计和分析设计 Apache SeaTunnel Zeta 为什么能做到“又快又稳”? AI开发-python-LangGraph框架(3-26-LangGraph基本概念及第一个简单样例) Vue 3 组件通信,别只会用 Props 和 Emits 了,这几个狠活儿你得看看 ElasticSearch7.X版本配置密码 用Manim实现动态交点计算--从一个动点问题说起 团结引擎+Addressable+Instant Game打包抖音小游戏 function call 实战:让 LLM 自动判断 pod 异常、调用日志工具并完成故障分析 bubseek —— 让 Agent 的足迹,变成团队的洞察 通过 C# 读取并导出 PDF 书签 如何用 GitHub Actions 实现 Steam 自动化发布 【从0到1构建一个ClaudeAgent】并发-后台任务 .NET 高级开发 | 定制 ASP.NET Core 框架 电子小白:什么是运算放大器(运放) zero2Agent:面向大厂面试的 Agent 工程教程,从概念到生产的完整学习路线 堆上的ORW HC32F460 USB CDC通信异常:非对齐访问异常排查 20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过 那些喊着AI 要淘汰你的人,正在靠你的焦虑赚大钱! 深度学习进阶(八)Swin Transformer 最小二乘问题详解19:带先验约束的增量式SFM优化与实现 SnapTranslate 3.0 正式发布:全局划词翻译 + 完整英语学习闭环,一站式搞定查词、记词、复习 工作的意义、工作的困难认知再思考 .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 开了 TUN 模式还是直连?90% 的人都踩过这个坑 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术
最小二乘问题详解20:无先验约束下的增量式SFM自由网平差
charlee44 · 2026-04-17 · via 博客园_首页

1 引言

在上两篇系列文章中,我们分别探讨了增量式 SfM 的核心骨架实现与多源信息融合策略。在第 18 篇《最小二乘问题详解18:增量式SFM核心流程实现》中,我们构建了算法的逻辑基座;而在第 19 篇《带先验约束的增量式 SFM 优化与实现》中,我们引入了 INS/GNSS 等外部位姿先验,构建了工业级稳健的重建系统。

回顾第 19 篇的带先验约束流程,我们不难发现,虽然该方案对数据采集设备提出了更高要求,但它实际上简化了 SfM 的核心难题。借助高精度的位姿先验,我们绕过了繁琐且脆弱的初始化步骤,能够直接基于已知的相机位置进行特征匹配与三角化,从而迅速展开全局光束法平差(BA)。在这一过程中,先验信息充当了“上帝之手”,直接为优化问题提供了高质量的初值,规避了纯视觉系统中常见的尺度漂移与拓扑错误。

然而,当我们剥离掉这些外部传感器提供的“辅助信息”,回归到计算机视觉最本源的2D-2D 特征对应关系时,增量式 SfM 才真正展现出其“从无到有”的魅力与挑战。这就是本文将要深入探讨的主题——无先验约束下的增量式 SfM 自由网平差

在这种纯视觉模式下,我们失去了绝对的坐标参考系,面对的是一片数据的“黑暗森林”。我们不仅不知道场景的绝对位置,甚至连相机之间的绝对距离(尺度)都无从得知。因此,我们无法像带先验流程那样直接从三角化开始,而是必须从 对极几何(Epipolar Geometry) 出发,通过计算本质矩阵(Essential Matrix, \(E\))来推导两视图之间的相对运动。

这一 初始化(Initialization) 过程至关重要,它是整个增量式 SfM 的关键点。本质矩阵 \(E\) 的求解质量,直接决定了初始两张像片的相对定向精度,进而作为后续所有相机位姿与 3D 结构生长的基准。一旦初始化失败或误差过大,这种误差将像病毒一样在增量扩展的过程中不断累积和放大,最终导致重建崩溃。因此,理解并掌握无先验约束下的自由网平差,不仅是对 SfM 算法本质的深刻洞察,也是解决最通用、最广泛视觉重建问题的核心能力。

2 子问题

首先还是先关注增量式 SfM 的几个核心子问题。

2.1 本质矩阵问题

第一点当然就是前文中提到了通过对极几何求本质矩阵 \(E\)。这个问题我们在《最小二乘问题详解13:对极几何中本质矩阵求解》和《最小二乘问题详解14:鲁棒估计与5点算法求解本质矩阵》这两篇文章中详细讨论过,将其具体实现封装一下:

#pragma once

#include <Eigen/Core>

class EssentialProblem {
 public:
  EssentialProblem();

  Eigen::Matrix3d Solve(const Eigen::Matrix3d& K,
                        const std::vector<Eigen::Vector3d>& x1s_noisy,
                        const std::vector<Eigen::Vector3d>& x2s_noisy,
                        double pixelThreshold, int ransacIterations);

 private:
  std::pair<Eigen::Matrix3d, std::vector<bool>> RansacEstimate(
      const std::vector<Eigen::Vector3d>& x1s_noisy,
      const std::vector<Eigen::Vector3d>& x2s_noisy, double pixel_threshold,
      double focal_length, int max_iterations);

  Eigen::Matrix3d EightPointAlgorithm(const std::vector<Eigen::Vector3d>& x1s,
                                      const std::vector<Eigen::Vector3d>& x2s);

  Eigen::Matrix3d OptimizeSampsonError(Eigen::Matrix3d E_init,
                                       const std::vector<Eigen::Vector3d>& x1s,
                                       const std::vector<Eigen::Vector3d>& x2s);

  Eigen::Matrix3d ComputeNormalization(
      const std::vector<Eigen::Vector3d>& points);

  double SampsonDistance(const Eigen::Matrix3d& E, const Eigen::Vector3d& x1,
                         const Eigen::Vector3d& x2);

  double EpipolarResidual(const Eigen::Matrix3d& E, const Eigen::Vector3d& x1,
                          const Eigen::Vector3d& x2);

  Eigen::Matrix3d NormalizeEssentialMatrix(const Eigen::Matrix3d& E);
};
#include "SolveEssential.h"

#include <ceres/ceres.h>

#include <Eigen/Geometry>
#include <iomanip>
#include <iostream>
#include <opengv/relative_pose/CentralRelativeAdapter.hpp>
#include <opengv/sac/Ransac.hpp>
#include <opengv/sac_problems/relative_pose/CentralRelativePoseSacProblem.hpp>
#include <random>
#include <vector>

#include "Math.h"

using namespace std;
using namespace Eigen;

namespace {
struct SampsonError {
  SampsonError(const Eigen::Vector3d& x1, const Eigen::Vector3d& x2)
      : x1_(x1), x2_(x2) {}

  template <typename T>
  bool operator()(const T* const e_ptr, T* residual) const {
    Eigen::Map<const Eigen::Matrix<T, 3, 3, Eigen::ColMajor>> E(e_ptr);

    Eigen::Matrix<T, 3, 1> x1_h(T(x1_(0)), T(x1_(1)), T(x1_(2)));
    Eigen::Matrix<T, 3, 1> x2_h(T(x2_(0)), T(x2_(1)), T(x2_(2)));

    T c = x2_h.transpose() * E * x1_h;
    Eigen::Matrix<T, 3, 1> Ex1 = E * x1_h;
    Eigen::Matrix<T, 3, 1> ETx2 = E.transpose() * x2_h;
    T denom = Ex1.squaredNorm() + ETx2.squaredNorm();

    if (denom < T(1e-12)) {
      residual[0] = T(1e5);
    } else {
      residual[0] = ceres::sqrt(c * c / denom);
    }
    return true;
  }

  static ceres::CostFunction* Create(const Eigen::Vector3d& x1,
                                     const Eigen::Vector3d& x2) {
    return new ceres::AutoDiffCostFunction<SampsonError, 1, 9>(
        new SampsonError(x1, x2));
  }

 private:
  Eigen::Vector3d x1_, x2_;
};
}  // namespace

EssentialProblem::EssentialProblem() {}

Eigen::Matrix3d EssentialProblem::Solve(
    const Eigen::Matrix3d& K, const std::vector<Eigen::Vector3d>& x1s_noisy,
    const std::vector<Eigen::Vector3d>& x2s_noisy, double pixelThreshold,
    int ransacIterations) {
  // RANSAC + 5点算法
  double focalLength = (K(0, 0) + K(1, 1)) * 0.5;
  auto [E_ransac, inlier_mask] = RansacEstimate(
      x1s_noisy, x2s_noisy, pixelThreshold, focalLength, ransacIterations);

  // 提取内点
  std::vector<Eigen::Vector3d> x1_inliers, x2_inliers;
  for (size_t i = 0; i < x1s_noisy.size(); ++i) {
    if (inlier_mask[i]) {
      x1_inliers.push_back(x1s_noisy[i]);
      x2_inliers.push_back(x2s_noisy[i]);
    }
  }

  // === 4. 初值:8点算法 ===
  Eigen::Matrix3d E_8pt = EightPointAlgorithm(x1_inliers, x2_inliers);
  std::cout << "=== 8-Point Estimate ===\n";
  std::cout << E_8pt << "\n\n";

  // === 5. 非线性优化:Sampson 误差 ===
  Eigen::Matrix3d E_final = OptimizeSampsonError(E_8pt, x1_inliers, x2_inliers);
  std::cout << "=== Final Optimized E (Projected) ===\n";
  std::cout << E_final << "\n\n";

  // === 6. 评估三项指标 ===
  double sampson_8pt = 0.0, sampson_final = 0.0;
  double epipolar_mse_8pt = 0.0, epipolar_mse_final = 0.0;
  int N = (int)x1_inliers.size();

  for (int i = 0; i < N; ++i) {
    sampson_8pt += SampsonDistance(E_8pt, x1_inliers[i], x2_inliers[i]);
    sampson_final += SampsonDistance(E_final, x1_inliers[i], x2_inliers[i]);

    double r1 = EpipolarResidual(E_8pt, x1_inliers[i], x2_inliers[i]);
    double r2 = EpipolarResidual(E_final, x1_inliers[i], x2_inliers[i]);
    epipolar_mse_8pt += r1 * r1;
    epipolar_mse_final += r2 * r2;
  }

  sampson_8pt /= N;
  sampson_final /= N;
  epipolar_mse_8pt /= N;
  epipolar_mse_final /= N;

  // 归一化 Frobenius 距离(代数相似性)
  Eigen::Matrix3d E_8pt_norm = NormalizeEssentialMatrix(E_8pt);
  Eigen::Matrix3d E_final_norm = NormalizeEssentialMatrix(E_final);

  // === 6. 输出评估结果 ===
  std::cout << "=== Evaluation Summary ===\n";
  std::cout << "Metric                     | 8-Point       | Optimized\n";
  std::cout << "---------------------------|---------------|------------\n";
  std::cout << "Avg Sampson Error          | " << setw(13) << sampson_8pt
            << " | " << sampson_final << "\n";
  std::cout << "Avg |x2^T E x1|^2 (MSE)   | " << setw(13) << epipolar_mse_8pt
            << " | " << epipolar_mse_final << "\n";

  return E_final;
}

// RANSAC + 5-point (OpenGV)
// 返回:Essential Matrix + inlier mask
std::pair<Eigen::Matrix3d, std::vector<bool>> EssentialProblem::RansacEstimate(
    const std::vector<Eigen::Vector3d>& x1s_noisy,
    const std::vector<Eigen::Vector3d>& x2s_noisy, double pixel_threshold,
    double focal_length, int max_iterations) {
  size_t N = x1s_noisy.size();

  if (x1s_noisy.size() != x2s_noisy.size() || x1s_noisy.size() < 5) {
    std::cerr << "Not enough correspondences.\n";
    return {Eigen::Matrix3d::Identity(), std::vector<bool>(N, false)};
  }

  // OpenGV expects unit bearing vectors
  opengv::bearingVectors_t b1(x1s_noisy.begin(), x1s_noisy.end());
  opengv::bearingVectors_t b2(x2s_noisy.begin(), x2s_noisy.end());

  opengv::relative_pose::CentralRelativeAdapter adapter(b1, b2);

  // 中心相机模型下的相对位姿 SAC 问题
  using SacProblem =
      opengv::sac_problems::relative_pose::CentralRelativePoseSacProblem;

  auto problem = std::make_shared<SacProblem>(adapter, SacProblem::NISTER);

  opengv::sac::Ransac<SacProblem> ransac;
  ransac.sac_model_ = problem;

  // 将像素级内点阈值(如1.0像素)转换为OpenGV所需的弦距离阈值
  // 步骤:pixel → 近似视角θ ≈ pixel/f → 弦距离 = 2*sin(θ/2)
  double theta_approx =
      pixel_threshold / focal_length;  // 小角度近似: θ ≈ tanθ = pixel/f
  ransac.threshold_ =
      2.0 * sin(theta_approx / 2.0);  // 转换为单位球面上的弦距离

  ransac.max_iterations_ = max_iterations;

  if (!ransac.computeModel()) {
    std::cerr << "RANSAC failed.\n";
    return {Eigen::Matrix3d::Identity(), std::vector<bool>(N, false)};
  }

  // OpenGV 返回的是 3x4 pose matrix [R | t]
  Eigen::Matrix<double, 3, 4> Rt = ransac.model_coefficients_;

  Eigen::Matrix3d R = Rt.leftCols<3>();
  Eigen::Vector3d t = Rt.rightCols<1>();

  // 构造 skew-symmetric matrix
  Eigen::Matrix3d t_hat;
  t_hat << 0, -t.z(), t.y(), t.z(), 0, -t.x(), -t.y(), t.x(), 0;

  Eigen::Matrix3d E = t_hat * R;

  // inlier mask
  std::vector<bool> inliers(N, false);

  for (int idx : ransac.inliers_) {
    if (idx >= 0 && idx < static_cast<int>(N)) inliers[idx] = true;
  }

  std::cout << "RANSAC inliers: " << ransac.inliers_.size() << " / " << N
            << std::endl;

  return {E, inliers};
}

using Vector9d = Eigen::Matrix<double, 9, 1>;

// ========================
// 工具函数
// ========================

Eigen::Matrix3d Vec2Mat(const Vector9d& e) {
  return Eigen::Map<const Eigen::Matrix3d>(e.data());
}

Vector9d Mat2Vec(const Eigen::Matrix3d& E) {
  return Eigen::Map<const Vector9d>(E.data());
}

// Sampson 距离计算
double EssentialProblem::SampsonDistance(const Eigen::Matrix3d& E,
                                         const Eigen::Vector3d& x1,
                                         const Eigen::Vector3d& x2) {
  double c = x2.transpose() * E * x1;
  Eigen::Vector3d Ex1 = E * x1;
  Eigen::Vector3d ETx2 = E.transpose() * x2;
  double denom = Ex1.squaredNorm() + ETx2.squaredNorm();
  if (denom < 1e-12) return 1e10;
  return c * c / denom;
}

// 对极约束残差:x2^T E x1
double EssentialProblem::EpipolarResidual(const Eigen::Matrix3d& E,
                                          const Eigen::Vector3d& x1,
                                          const Eigen::Vector3d& x2) {
  return x2.transpose() * E * x1;
}

// ========================
// 辅助函数:计算归一化变换矩阵 T
// ========================
Eigen::Matrix3d EssentialProblem::ComputeNormalization(
    const std::vector<Eigen::Vector3d>& points) {
  Eigen::Vector2d centroid(0, 0);
  for (const auto& p : points) {
    centroid += p.head<2>();
  }
  centroid /= points.size();

  double avg_dist = 0.0;
  for (const auto& p : points) {
    avg_dist += (p.head<2>() - centroid).norm();
  }
  avg_dist /= points.size();

  double scale = sqrt(2.0) / avg_dist;
  Eigen::Matrix3d T = Eigen::Matrix3d::Identity();
  T(0, 0) = T(1, 1) = scale;
  T(0, 2) = -scale * centroid(0);
  T(1, 2) = -scale * centroid(1);
  return T;
}

// ========================
// 8点算法实现
// ========================
Eigen::Matrix3d EssentialProblem::EightPointAlgorithm(
    const std::vector<Eigen::Vector3d>& x1s,
    const std::vector<Eigen::Vector3d>& x2s) {
  Eigen::Matrix3d T1 = ComputeNormalization(x1s);
  Eigen::Matrix3d T2 = ComputeNormalization(x2s);

  std::vector<Eigen::Vector3d> nx1s, nx2s;
  for (size_t i = 0; i < x1s.size(); ++i) {
    nx1s.push_back(T1 * x1s[i]);
    nx2s.push_back(T2 * x2s[i]);
  }

  size_t N = nx1s.size();
  Eigen::MatrixXd A(N, 9);
  for (size_t i = 0; i < N; ++i) {
    double u1 = nx1s[i](0), v1 = nx1s[i](1);
    double u2 = nx2s[i](0), v2 = nx2s[i](1);
    A.row(i) << u1 * u2, v1 * u2, u2, u1 * v2, v1 * v2, v2, u1, v1, 1.0;
  }

  Eigen::JacobiSVD<Eigen::MatrixXd> svd(A, Eigen::ComputeFullV);
  Vector9d e = svd.matrixV().col(8);
  Eigen::Matrix3d E_tilde = Vec2Mat(e);

  Eigen::JacobiSVD<Eigen::Matrix3d> svd_E(
      E_tilde, Eigen::ComputeFullU | Eigen::ComputeFullV);
  Eigen::Vector3d s = svd_E.singularValues();
  double avg = (s(0) + s(1)) / 2.0;
  Eigen::Matrix3d E_normalized =
      svd_E.matrixU() * Eigen::DiagonalMatrix<double, 3, 3>(avg, avg, 0.0) *
      svd_E.matrixV().transpose();

  return T2.transpose() * E_normalized * T1;
}

// 归一化 E 使得 ||E||_F = 1
Eigen::Matrix3d EssentialProblem::NormalizeEssentialMatrix(
    const Eigen::Matrix3d& E) {
  return E / E.norm();
}

// 基于 Sampson 误差的非线性优化
Eigen::Matrix3d EssentialProblem::OptimizeSampsonError(
    Eigen::Matrix3d E_init, const std::vector<Eigen::Vector3d>& x1s,
    const std::vector<Eigen::Vector3d>& x2s) {
  Vector9d e_opt = Mat2Vec(E_init);
  ceres::Problem problem;
  for (int i = 0; i < x1s.size(); ++i) {
    problem.AddResidualBlock(SampsonError::Create(x1s[i], x2s[i]), nullptr,
                             e_opt.data());
  }

  ceres::Solver::Options options;
  options.linear_solver_type = ceres::DENSE_QR;
  options.minimizer_progress_to_stdout = true;
  options.max_num_iterations = 50;

  ceres::Solver::Summary summary;
  ceres::Solve(options, &problem, &summary);
  std::cout << "\n" << summary.BriefReport() << "\n";

  Eigen::Matrix3d E_opt = Vec2Mat(e_opt);

  // 投影回本质矩阵流形(强制 σ1=σ2, σ3=0)
  Eigen::JacobiSVD<Eigen::Matrix3d> svd(
      E_opt, Eigen::ComputeFullU | Eigen::ComputeFullV);
  Eigen::Vector3d s = svd.singularValues();
  double avg = (s(0) + s(1)) / 2.0;
  Eigen::Matrix3d E_final = svd.matrixU() *
                            Eigen::DiagonalMatrix<double, 3, 3>(avg, avg, 0.0) *
                            svd.matrixV().transpose();

  return E_final;
}

这段代码实现还是遵循 5 点算法 + RANSAC 剔除外点 + 8 点算法计算初值 + 非线性最小二乘优化 Sampson 误差的流程。这种稳健的实现对于这里使用的相对简单的仿真数据也是适用的。

2.2 PnP问题

PnP问题是纯视觉的增量式 SFM 自由网平差的另一个关键。新注册的图像如果不能恢复姿态,后续的优化流程就不能正常继续。在带位姿先验的 PnP 问题中,我们可以把位姿先验作为初值来进行非线性优化,估值过程可以很快收敛。在仅有 2D-2D 数据的纯视觉环境下,由于缺乏可靠的位姿先验作为初值参考,优化过程只能以随机初始化或基于简单启发式规则生成的初值为起点,极易陷入局部最优解,导致算法难以收敛。

为了解决这个问题,可以引入高效的线性求解器来提供高质量的初始估计:也就是 EPnP 算法。具体来说,采用 EPnP + 非线性优化 的两阶段策略:首先利用 EPnP 算法快速计算出相机位姿的解析解(Closed-form Solution),以此作为初值;随后将其输入到 Ceres 等非线性优化库中进行精细迭代。

EPnP 的核心思想非常巧妙,它通过引入 虚拟控制点 的概念,将复杂的非线性问题转化为线性问题求解。并且求解过程也不是简单的线性回归,而是一个夹杂了代数分解步骤的线性最小二乘解

  1. 虚拟控制点与线性化
    算法首先引入了 4 个“虚拟控制点”的概念。它的核心假设是:场景中任意的 3D 点,都可以表示为这 4 个控制点的加权和。
    既然 3D 点可以用控制点表示,那么它们在相机坐标系下的坐标,自然也可以用相机坐标系下的这 4 个控制点来表示,且权重保持不变。
    利用这个性质,结合相机的投影方程,我们可以构建一个关于“相机坐标系下 4 个控制点坐标”的线性方程组(形式为 \(Mx = 0\))。这一步将原本非线性的 PnP 问题转化为了一个线性最小二乘问题。
  2. SVD 分解与代数求解
    构建好方程组后,我们通过 SVD 分解 来求解这个线性系统,得到控制点在相机坐标系下的基础解系。
    但此时解仍然存在尺度模糊(即不知道具体的缩放比例)。为了解决这个问题,EPnP 利用了“控制点之间的欧氏距离在坐标系变换下保持不变”这一几何约束。通过建立距离约束方程,算法可以解算出正确的缩放系数,从而唯一确定控制点在相机坐标系下的精确位置。
  3. 位姿恢复
    一旦我们知道了 4 个控制点在世界坐标系和相机坐标系下的坐标,问题就退化为了一个简单的绝对定向问题。此时,只需计算两组点集之间的最佳刚体变换,即可直接解出相机的旋转矩阵 \(R\) 和平移向量 \(t\)

虽然 EPnP 的核心数学原理基于线性最小二乘和 SVD 分解,但其工程实现涉及复杂的控制点选取策略、零空间维度分类讨论以及多项式求根过程。这个实现过程相当繁复,可以使用计算机视觉领域标准的 OpenGV 库来求解 EPnP。EPnP + 非线性优化的具体实现如下所示:

#pragma once

#include <Eigen/Core>
#include <Eigen/Geometry>
#include <vector>

class PnPProblem {
 public:
  PnPProblem(const Eigen::Matrix3d& K,
             const std::vector<Eigen::Vector3d>& points_world,
             const std::vector<Eigen::Vector2d>& observations);

  bool Solve(Eigen::Matrix3d& R_opt, Eigen::Vector3d& t_opt);

 private:
  bool SolveInitialPose(const std::vector<Eigen::Vector3d>& points_world,
                        const std::vector<Eigen::Vector2d>& observations,
                        double fx, double fy, double cx, double cy,
                        double* q_out,  // [w, x, y, z]
                        double* t_out   // [x, y, z]
  );

  Eigen::Quaterniond CeresQuatToEigen(const double* q_ceres);
  void EigenQuatToCeres(const Eigen::Quaterniond& q_eigen, double* q_ceres);

  double fx;
  double fy;
  double cx;
  double cy;

  const std::vector<Eigen::Vector3d>& points_world;
  const std::vector<Eigen::Vector2d>& observations;
};
#include "SolvePnP.h"

#include <ceres/ceres.h>
#include <ceres/rotation.h>

#include <iomanip>
#include <iostream>
#include <opengv/absolute_pose/CentralAbsoluteAdapter.hpp>
#include <opengv/absolute_pose/methods.hpp>
#include <opengv/sac/Ransac.hpp>
#include <opengv/sac_problems/absolute_pose/AbsolutePoseSacProblem.hpp>
#include <vector>

#include "Math.h"

namespace {
struct ReprojectionError {
  ReprojectionError(const Eigen::Vector3d& X_, const Eigen::Vector2d& obs_,
                    double fx_, double fy_, double cx_, double cy_)
      : X(X_), obs(obs_), fx(fx_), fy(fy_), cx(cx_), cy(cy_) {}

  template <typename T>
  bool operator()(const T* const q, const T* const t, T* residuals) const {
    T p_camera[3];
    T X_world[3] = {T(X.x()), T(X.y()), T(X.z())};

    // Ceres 期望 q 顺序为 [w, x, y, z]
    ceres::QuaternionRotatePoint(q, X_world, p_camera);

    p_camera[0] += t[0];
    p_camera[1] += t[1];
    p_camera[2] += t[2];

    T xp = p_camera[0] / p_camera[2];
    T yp = p_camera[1] / p_camera[2];

    T u = T(fx) * xp + T(cx);
    T v = T(fy) * yp + T(cy);

    residuals[0] = u - T(obs.x());
    residuals[1] = v - T(obs.y());

    return true;
  }

  static ceres::CostFunction* Create(const Eigen::Vector3d& X,
                                     const Eigen::Vector2d& obs, double fx,
                                     double fy, double cx, double cy) {
    return new ceres::AutoDiffCostFunction<ReprojectionError, 2, 4, 3>(
        new ReprojectionError(X, obs, fx, fy, cx, cy));
  }

  Eigen::Vector3d X;
  Eigen::Vector2d obs;
  double fx, fy, cx, cy;
};
}  // namespace

PnPProblem::PnPProblem(const Eigen::Matrix3d& K,
                       const std::vector<Eigen::Vector3d>& points_world,
                       const std::vector<Eigen::Vector2d>& observations)
    : fx(K(0, 0)),
      fy(K(1, 1)),
      cx(K(0, 2)),
      cy(K(1, 2)),
      points_world(points_world),
      observations(observations) {}

bool PnPProblem::Solve(Eigen::Matrix3d& R_opt, Eigen::Vector3d& t_opt) {
  // --- 使用 OpenGV EPnP 计算初值 ---
  double q_est[4] = {1, 0, 0, 0};
  double t_est[3] = {0, 0, 0};

  std::cout << "\n=== Computing Initial Value with OpenGV EPnP ==="
            << std::endl;
  if (!SolveInitialPose(points_world, observations, fx, fy, cx, cy, q_est,
                        t_est)) {
    std::cerr << "Failed to compute initial pose. Falling back to identity."
              << std::endl;
    // Fallback
    Eigen::Quaterniond q_init = Eigen::Quaterniond::Identity();
    EigenQuatToCeres(q_init, q_est);
    t_est[0] = t_est[1] = t_est[2] = 0.0;
  }

  // --- 构建 Ceres 问题 ---
  ceres::Problem problem;
  ceres::Manifold* quaternion_manifold = new ceres::QuaternionManifold();

  problem.AddParameterBlock(q_est, 4, quaternion_manifold);
  problem.AddParameterBlock(t_est, 3);

  for (size_t i = 0; i < points_world.size(); ++i) {
    problem.AddResidualBlock(
        ReprojectionError::Create(points_world[i], observations[i], fx, fy, cx,
                                  cy),
        nullptr, q_est, t_est);
  }

  ceres::Solver::Options options;
  options.linear_solver_type = ceres::DENSE_QR;
  options.minimizer_progress_to_stdout = true;
  options.max_num_iterations = 50;

  ceres::Solver::Summary summary;
  std::cout << "\n=== Starting Non-linear Optimization (Ceres) ==="
            << std::endl;
  ceres::Solve(options, &problem, &summary);
  std::cout << summary.BriefReport() << "\n";

  // --- 结果评估 ---
  Eigen::Quaterniond q_opt = CeresQuatToEigen(q_est);
  q_opt.normalize();
  R_opt = q_opt.toRotationMatrix();
  t_opt << t_est[0], t_est[1], t_est[2];

  // 计算误差
  double total_error = 0.0;
  for (size_t i = 0; i < points_world.size(); ++i) {
    Eigen::Vector3d pc = q_opt * points_world[i] + t_opt;
    double u = fx * pc.x() / pc.z() + cx;
    double v = fy * pc.y() / pc.z() + cy;
    double err_x = u - observations[i].x();
    double err_y = v - observations[i].y();
    total_error += err_x * err_x + err_y * err_y;
  }
  double rmse = std::sqrt(total_error / points_world.size());

  std::cout << "\n=== Accuracy Check ===" << std::endl;
  std::cout << "Final RMSE:          " << rmse << " pixels" << std::endl;

  // === 3. 核心判断:仅根据终止类型决定成败 ===
  if (summary.termination_type != ceres::CONVERGENCE) {
    return false;
  }
  if (rmse > 6.0) {  // 阈值根据情况调整,合成数据可以给小一点
    std::cout << "PnP Failed: Converged but RMSE too high." << std::endl;
    return false;
  }

  return true;
}

// 使用 OpenGV EPnP 计算初值
bool PnPProblem::SolveInitialPose(
    const std::vector<Eigen::Vector3d>& points_world,
    const std::vector<Eigen::Vector2d>& observations, double fx, double fy,
    double cx, double cy, double* q_out, double* t_out) {
  if (points_world.size() < 4) {
    std::cerr << "[OpenGV EPnP] Not enough points (need >= 4)" << std::endl;
    return false;
  }

  // 1. 准备 OpenGV 的数据结构
  // OpenGV 通常使用 bearing vectors (归一化射线) 或者直接在 adapter 中处理内参
  // 这里我们使用 CentralAbsoluteAdapter,它需要归一化坐标 (x, y, 1)

  opengv::points_t pts_world;      // 3D 点 (世界系)
  opengv::bearingVectors_t bears;  // 归一化观测射线 (相机系)

  pts_world.reserve(points_world.size());
  bears.reserve(observations.size());

  for (size_t i = 0; i < points_world.size(); ++i) {
    // 填入世界点
    pts_world.push_back(points_world[i]);

    // 将像素坐标转为归一化平面坐标 (x, y, 1)
    double u = observations[i].x();
    double v = observations[i].y();

    double nx = (u - cx) / fx;
    double ny = (v - cy) / fy;

    Eigen::Vector3d bearing_vector(nx, ny, 1.0);
    bearing_vector.normalize();  // 确保是单位向量
    bears.push_back(bearing_vector);
  }

  // 2. 创建 Adapter
  // CentralAbsoluteAdapter 适用于单相机绝对位姿估计
  opengv::absolute_pose::CentralAbsoluteAdapter adapter(bears, pts_world);

  // 3. 调用 EPnP
  // 返回值是 4x4 的变换矩阵 T_cam_world (即 [R|t])
  // 注意:OpenGV 的坐标系定义通常是 X 右,Y 下,Z 前 (符合计算机视觉习惯)
  opengv::transformation_t T_cw = opengv::absolute_pose::epnp(adapter);

  // 4. 提取 R 和 t
  Eigen::Matrix3d R_cw = T_cw.block<3, 3>(0, 0);
  Eigen::Vector3d t_cw = T_cw.block<3, 1>(0, 3);

  // Cheirality check:初值的 cheirality 比误差更重要
  int front_count = 0;
  for (size_t i = 0; i < pts_world.size(); ++i) {
    Eigen::Vector3d pc = R_cw * pts_world[i] + t_cw;
    if (pc.z() > 0) front_count++;
  }

  if (front_count < pts_world.size() / 2) {
    R_cw = -R_cw;
    t_cw = -t_cw;
  }

  // 5. 转换为四元数
  Eigen::Quaterniond q_cw(R_cw);
  q_cw.normalize();

  // 6. 输出到 Ceres 格式数组
  // Ceres 期望四元数顺序: [w, x, y, z]
  q_out[0] = q_cw.w();
  q_out[1] = q_cw.x();
  q_out[2] = q_cw.y();
  q_out[3] = q_cw.z();

  t_out[0] = t_cw.x();
  t_out[1] = t_cw.y();
  t_out[2] = t_cw.z();

  std::cout << "[OpenGV EPnP] Initial pose computed successfully." << std::endl;
  std::cout << "  Init Rot (w,x,y,z): [" << q_out[0] << ", " << q_out[1] << ", "
            << q_out[2] << ", " << q_out[3] << "]" << std::endl;
  std::cout << "  Init Trans (x,y,z): [" << t_out[0] << ", " << t_out[1] << ", "
            << t_out[2] << "]" << std::endl;

  return true;
}

Eigen::Quaterniond PnPProblem::CeresQuatToEigen(const double* q_ceres) {
  return Eigen::Quaterniond(q_ceres[0], q_ceres[1], q_ceres[2], q_ceres[3]);
}

void PnPProblem::EigenQuatToCeres(const Eigen::Quaterniond& q_eigen,
                                  double* q_ceres) {
  q_ceres[0] = q_eigen.w();
  q_ceres[1] = q_eigen.x();
  q_ceres[2] = q_eigen.y();
  q_ceres[3] = q_eigen.z();
}

3 主流程

在完善了本质矩阵问题和PnP问题的实现之后,就可以进行修改增量式 SFM 框架的主流程了,具体代码如下:

#include <Eigen/Dense>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <optional>

#include "Bundle.h"
#include "CameraIntrinsics.hpp"
#include "CameraPose.hpp"
#include "Math.h"
#include "Output.h"
#include "ReadData.h"
#include "SFMData.h"
#include "SolveBA.h"
#include "SolveEssential.h"
#include "SolvePnP.h"
#include "SolveTriangulate.h"

using namespace std;

bool GlobalBundleAdjustment(const Eigen::Matrix3d& K, const sfm::Bundle& bundle,
                            vector<cl::CameraExtrinsics>& cameras,
                            vector<cl::ObjectPoint>& objectPoints) {
  map<int, int> cameraIdMap;
  vector<BAProblem::CameraExtrinsics> est_cams;
  for (size_t ci = 0; ci < cameras.size(); ++ci) {
    if (cameras[ci].registered) {
      BAProblem::CameraExtrinsics cameraExtrinsics;
      Eigen::Quaterniond quaternion(cameras[ci].R);
      cameraExtrinsics.q[0] = quaternion.w();
      cameraExtrinsics.q[1] = quaternion.x();
      cameraExtrinsics.q[2] = quaternion.y();
      cameraExtrinsics.q[3] = quaternion.z();
      cameraExtrinsics.t[0] = cameras[ci].t.x();
      cameraExtrinsics.t[1] = cameras[ci].t.y();
      cameraExtrinsics.t[2] = cameras[ci].t.z();
      cameraIdMap[ci] = est_cams.size();
      est_cams.push_back(std::move(cameraExtrinsics));
    }
  }

  vector<BAProblem::ObjectPoint> est_pts;
  vector<BAProblem::Observation> obs;
  map<int, int> pointIdMap;

  for (const auto& track : bundle.tracks) {
    if (objectPoints[track.pointId].triangulated) {
      BAProblem::ObjectPoint objectPoint;
      objectPoint.xyz[0] = objectPoints[track.pointId].pos.x();
      objectPoint.xyz[1] = objectPoints[track.pointId].pos.y();
      objectPoint.xyz[2] = objectPoints[track.pointId].pos.z();

      for (auto ob : track.obs) {
        auto iter = cameraIdMap.find(ob.imgId);
        if (iter != cameraIdMap.end()) {
          BAProblem::Observation observation;
          observation.cam_id = iter->second;
          observation.pt_id = est_pts.size();
          observation.x = bundle.views[ob.imgId][ob.kpId].x();
          observation.y = bundle.views[ob.imgId][ob.kpId].y();
          obs.push_back(std::move(observation));
        }
      }
      pointIdMap.emplace(track.pointId, est_pts.size());
      est_pts.push_back(objectPoint);
    }
  }

  BAProblem problem(K, obs, est_cams, est_pts);
  if (!problem.Solve()) {
    return false;
  }

  for (const auto& [oldId, id] : cameraIdMap) {
    Eigen::Quaternion q(est_cams[id].q[0], est_cams[id].q[1], est_cams[id].q[2],
                        est_cams[id].q[3]);
    cameras[oldId].R = q.toRotationMatrix();
    cameras[oldId].t.x() = est_cams[id].t[0];
    cameras[oldId].t.y() = est_cams[id].t[1];
    cameras[oldId].t.z() = est_cams[id].t[2];
  }

  for (auto track : bundle.tracks) {
    if (objectPoints[track.pointId].triangulated) {
      auto iter = pointIdMap.find(track.pointId);
      if (iter != pointIdMap.end()) {
        objectPoints[track.pointId].pos.x() = est_pts[iter->second].xyz[0];
        objectPoints[track.pointId].pos.y() = est_pts[iter->second].xyz[1];
        objectPoints[track.pointId].pos.z() = est_pts[iter->second].xyz[2];
      }
    }
  }

  return true;
}

bool RegisterNextImage(const Eigen::Matrix3d& K, const sfm::Bundle& bundle,
                       vector<cl::CameraExtrinsics>& cameraExtrinsics,
                       vector<cl::ObjectPoint>& objectPoints) {
  map<int, vector<pair<int, int>>> mapImgTo3dAnd2d;
  for (const auto& track : bundle.tracks) {
    for (const auto& ob : track.obs) {
      if (cameraExtrinsics[ob.imgId].registered) {
        continue;
      }
      if (objectPoints[track.pointId].triangulated) {
        mapImgTo3dAnd2d[ob.imgId].push_back({track.pointId, ob.kpId});
      }
    }
  }

  // 暂时选择点数最多的影像
  int bestImgId = -1;
  int counter = 0;
  for (const auto& [imgId, ids] : mapImgTo3dAnd2d) {
    if (ids.size() > counter) {
      bestImgId = imgId;
      counter = ids.size();
    }
  }

  if (bestImgId == -1) {
    return false;
  }

  const auto& idsMap = mapImgTo3dAnd2d[bestImgId];
  vector<Eigen::Vector3d> points;
  vector<Eigen::Vector2d> observations;
  for (const auto& [id3d, id2d] : idsMap) {
    points.emplace_back(objectPoints[id3d].pos);
    observations.emplace_back(bundle.views[bestImgId][id2d]);
  }

  Eigen::Matrix3d R_est = cameraExtrinsics[bestImgId].R;
  Eigen::Vector3d t_est = cameraExtrinsics[bestImgId].t;
  PnPProblem problem(K, points, observations);
  if (problem.Solve(R_est, t_est)) {
    cameraExtrinsics[bestImgId].R = R_est;
    cameraExtrinsics[bestImgId].t = t_est;
    cameraExtrinsics[bestImgId].registered = true;
    return true;
  }

  return false;
}

void TriangulateNewPoints(const Eigen::Matrix3d& K, const sfm::Bundle& bundle,
                          vector<cl::CameraExtrinsics>& cameraExtrinsics,
                          vector<cl::ObjectPoint>& objectPoints) {
  for (auto track : bundle.tracks) {
    if (objectPoints[track.pointId].triangulated) {
      continue;
    }

    vector<sfm::Observation> registerObservation;
    for (const auto& ob : track.obs) {
      if (cameraExtrinsics[ob.imgId].registered) {
        registerObservation.push_back(ob);
      }
    }

    if (registerObservation.size() < 2) {
      continue;
    }

    std::vector<Eigen::Matrix3d> Rs;
    std::vector<Eigen::Vector3d> ts;
    std::vector<Eigen::Vector2d> observations;
    for (const auto& ob : registerObservation) {
      Rs.push_back(cameraExtrinsics[ob.imgId].R);
      ts.push_back(cameraExtrinsics[ob.imgId].t);
      observations.push_back(bundle.views[ob.imgId][ob.kpId]);
    }

    // 尝试三角化
    Eigen::Vector3d X = Eigen::Vector3d::Zero();
    TriangulateProblem triangulateProblem(K, Rs, ts, observations);
    if (!triangulateProblem.Solve(X)) {
      continue;
    }

    objectPoints[track.pointId].pos = X;
    objectPoints[track.pointId].triangulated = true;
  }
}

void DecomposeEssentialMatrix(const Eigen::Matrix3d& E,
                              std::vector<Eigen::Matrix3d>& Rs,
                              std::vector<Eigen::Vector3d>& ts) {
  Eigen::JacobiSVD<Eigen::Matrix3d> svd(
      E, Eigen::ComputeFullU | Eigen::ComputeFullV);

  Eigen::Matrix3d U = svd.matrixU();
  Eigen::Matrix3d Vt = svd.matrixV().transpose();

  // 保证 det(UV^T) = +1
  if ((U * Vt).determinant() < 0) {
    U.col(2) *= -1;
  }

  Eigen::Matrix3d W;
  W << 0, -1, 0, 1, 0, 0, 0, 0, 1;

  Eigen::Matrix3d R1 = U * W * Vt;
  Eigen::Matrix3d R2 = U * W.transpose() * Vt;

  Eigen::Vector3d t = U.col(2);

  Rs = {R1, R1, R2, R2};
  ts = {t, -t, t, -t};
}

int SelectBestRt(const std::vector<Eigen::Matrix3d>& Rs,
                 const std::vector<Eigen::Vector3d>& ts,
                 const std::vector<Eigen::Vector2d>& x1s,
                 const std::vector<Eigen::Vector2d>& x2s,
                 const Eigen::Matrix3d& K) {
  int best_idx = -1;
  int max_positive = -1;

  for (size_t i = 0; i < Rs.size(); ++i) {
    int positive_count = 0;

    Eigen::Matrix3d R = Rs[i];
    Eigen::Vector3d t = ts[i];

    for (int j = 0; j < x1s.size(); ++j) {
      Eigen::Vector3d X = Eigen::Vector3d::Zero();
      TriangulateProblem triangulateProblem(K, {Eigen::Matrix3d::Identity(), R},
                                            {Eigen::Vector3d::Zero(), t},
                                            {x1s[j], x2s[j]});
      if (!triangulateProblem.Solve(X)) {
        continue;
      }

      double z1 = X.z();
      double z2 = (R * X + t).z();

      if (z1 > 0 && z2 > 0) {
        positive_count++;
      }
    }

    if (positive_count > max_positive) {
      max_positive = positive_count;
      best_idx = i;
    }
  }

  return best_idx;
}

void Init(const Eigen::Matrix3d& K, const sfm::Bundle& bundle,
          vector<cl::CameraExtrinsics>& cameraExtrinsics,
          vector<cl::ObjectPoint>& objectPoints) {
  // 从匹配图中找到重叠度最高、基线最合适的两张图

  const auto& tracks = bundle.tracks;

  map<pair<int, int>, int> imagePairCounter;
  for (const auto& track : tracks) {
    if (track.obs.size() < 2) {
      continue;
    }

    for (size_t i = 0; i < track.obs.size(); ++i) {
      for (size_t j = i + 1; j < track.obs.size(); ++j) {
        pair<int, int> imagePair{track.obs[i].imgId, track.obs[j].imgId};
        auto iter = imagePairCounter.find(imagePair);
        if (imagePairCounter.end() == iter) {
          imagePairCounter.emplace(imagePair, 1);
        } else {
          iter->second += 1;
        }
      }
    }
  }

  // 暂时只选取匹配点最多的
  int counter = 0;
  int image1 = -1;
  int image2 = -1;
  for (const auto& [imagePair, count] : imagePairCounter) {
    if (count > counter) {
      counter = count;
      image1 = imagePair.first;
      image2 = imagePair.second;
    }
  }
  if (image1 == -1 || image2 == -1) {
    return;
  }

  // 找到同名点
  std::vector<Eigen::Vector2d> pixel1coords;
  std::vector<Eigen::Vector2d> pixel2coords;
  std::vector<Eigen::Vector3d> x1s_noisy;
  std::vector<Eigen::Vector3d> x2s_noisy;
  for (const auto& track : tracks) {
    if (track.obs.size() < 2) {
      continue;
    }

    for (size_t i = 0; i < track.obs.size(); ++i) {
      for (size_t j = i + 1; j < track.obs.size(); ++j) {
        if (track.obs[i].imgId == image1 && track.obs[j].imgId == image2) {
          const auto& view1 = bundle.views[image1][track.obs[i].kpId];
          const auto& view2 = bundle.views[image2][track.obs[j].kpId];

          // 像素坐标
          pixel1coords.push_back(view1);
          pixel2coords.push_back(view2);

          // 转为归一化坐标
          Eigen::Vector3d pixel1_noisy(view1.x(), view1.y(), 1);
          Eigen::Vector3d pixel2_noisy(view2.x(), view2.y(), 1);

          Eigen::Vector3d x1_noisy = (K.inverse() * pixel1_noisy).normalized();
          Eigen::Vector3d x2_noisy = (K.inverse() * pixel2_noisy).normalized();

          x1s_noisy.push_back(x1_noisy);
          x2s_noisy.push_back(x2_noisy);
        }
      }
    }
  }

  // 求解本质矩阵
  EssentialProblem essentialProblem;
  Eigen::Matrix3d E = essentialProblem.Solve(K, x1s_noisy, x2s_noisy, 1.0, 100);

  std::vector<Eigen::Matrix3d> Rs;
  std::vector<Eigen::Vector3d> ts;
  DecomposeEssentialMatrix(E, Rs, ts);

  int bestIdx = SelectBestRt(Rs, ts, pixel1coords, pixel2coords, K);

  // 相机 1 为原点,单位矩阵旋转
  cameraExtrinsics[image1].registered = true;
  cameraExtrinsics[image1].R = Eigen::Matrix3d::Identity();
  cameraExtrinsics[image1].t = Eigen::Vector3d::Zero();

  // 相机 2 的位姿 = 相对位姿
  cameraExtrinsics[image2].registered = true;
  cameraExtrinsics[image2].R = Rs[bestIdx];
  cameraExtrinsics[image2].t = ts[bestIdx];

  TriangulateNewPoints(K, bundle, cameraExtrinsics, objectPoints);

  GlobalBundleAdjustment(K, bundle, cameraExtrinsics, objectPoints);
}

void Iterate(const Eigen::Matrix3d& K, const sfm::Bundle& bundle,
             vector<cl::CameraExtrinsics>& cameraExtrinsics,
             vector<cl::ObjectPoint>& objectPoints) {
  while (true) {
    cout << "--- 新一轮迭代 ---" << endl;

    // 1. 尝试注册一张新图片
    // 返回 true 表示成功注册了一张,false 表示没有更多图片可注册
    bool success = RegisterNextImage(K, bundle, cameraExtrinsics, objectPoints);

    if (!success) {
      cout << "没有更多图片可以注册,结束。" << endl;
      break;
    }

    // 2. 三角化新点
    // 利用新注册的相机,与已注册相机之间的匹配,生成新的 3D 点
    TriangulateNewPoints(K, bundle, cameraExtrinsics, objectPoints);

    // 4. 全局光束法平差 (Global BA)
    // 优化所有已注册相机的位姿和所有 3D 点坐标
    cout << "----GlobalBundleAdjustment----" << endl;
    if (!GlobalBundleAdjustment(K, bundle, cameraExtrinsics, objectPoints)) {
      break;
    }

    // break;
  }

  cout << "=== SfM 完成 ===" << endl;
  {
    int counter = 0;
    for (const auto& cam : cameraExtrinsics) {
      if (cam.registered) {
        counter++;
      }
    }
    cout << "最终相机数: " << counter << endl;
  }
  {
    int counter = 0;
    for (const auto& point : objectPoints) {
      if (point.triangulated) {
        counter++;
      }
    }
    cout << "最终 3D 点数: " << counter << endl;
  }
}

void IncrementalSFM(const Eigen::Matrix3d& K,
                    vector<cl::CameraExtrinsics>& cameraExtrinsics,
                    const sfm::Bundle& bundle,
                    vector<cl::ObjectPoint>& objectPoints) {
  // 初始化启动
  Init(K, bundle, cameraExtrinsics, objectPoints);
  // 迭代优化
  Iterate(K, bundle, cameraExtrinsics, objectPoints);
}

int main() {
  std::string cameraIntrinsicsPath =
      "D:/Work/SFMWork/SFM/CameraIntrinsics.json";
  std::string bundlePath = "D:/Work/SFMWork/SFM/Bundle.json";
  std::string outputDir = "D:/Work/SFMWork/SFM";

  auto [K, cameraExtrinsics, bundle, objectPoints] =
      ReadData(cameraIntrinsicsPath, bundlePath);

  IncrementalSFM(K, cameraExtrinsics, bundle, objectPoints);

  Output(K, cameraExtrinsics, bundle, objectPoints, outputDir);
}

在这段代码实现中,有两个关键点体现了本文的无先验实现与先前的依赖先验的不同:

3.1 本质矩阵启动

在带先验的流程中,我们拥有绝对的坐标参考,可以直接进行三角化。但在纯视觉自由网平差中,初始状态下所有数据都是“漂浮”的,我们无法直接确定尺度,必须先通过 对极几何 求解本质矩阵 \(E\),从而恢复两视图之间的相对运动(旋转 \(R\) 和平移方向 \(t\))。

这就引出了增量式 SfM 中最关键的初始化步骤。代码中的 Init 函数首先遍历所有匹配轨迹,寻找重叠度最高的一对图像作为“种子”。这里的实现逻辑较为简单,仅选取了同名点对数量最多的一对图像。在实际工程中,仅看点数是不够的。理想的初始化对还需要满足基线条件——基线太短会导致三角化深度误差极大,基线太长则匹配困难。因此,更稳健的策略通常会综合考量“匹配点数”与“视差大小”,选取几何分布最优的一对图像来构建初始骨架。

3.2 歧义性消除

本质矩阵 \(E\) 的分解在数学上存在四义性,即会分解出 4 组可能的 \((R, t)\) 组合。这意味着,仅凭数学推导,我们无法确定相机是向前平移还是向后平移,也无法确定物体是在相机前方还是后方。
为了从这 4 组解中选出唯一正确的物理真实解,我们需要进行手性检查

  • 原理:正确的相机位姿应当使得三角化后的 3D 点位于两个相机的正前方(即深度 \(Z > 0\))。
  • 实现:代码中的 SelectBestRt 函数正是执行这一逻辑。它尝试将匹配点三角化,并统计在每种 \((R, t)\) 组合下,有多少个点的深度值为正。最终,我们选取那个能让最多点落在相机前方的解作为初始位姿。

虽然 OpenGV 的 RANSAC + 5点算法在内部已经处理了这一过程,但在本系统中,为了追求极致的严密性,我们采用了 “RANSAC 滤除外点 + 8点算法计算初值 + 非线性优化 Sampson 误差” 的流程。这使得求解出的 \(E\) 矩阵更加精准,但同时也要求我们必须手动严谨地处理分解后的歧义性,确保增量式重建的“第一步”迈得稳健而准确。

3.3 运行结果

在完成无先验约束的增量式 SfM 自由网平差后,我们得到了如下的重建结果:

=== SfM 重建精度报告 ===

=== 总体重投影误差评估 ===
总有效观测点数量: 213825
均方根重投影误差 (RMSE): 0.925533 像素
平均重投影距离: 1.308901 像素
最大重投影误差: 4.736852 像素

评估结果: 优秀 (RMSE < 1.0 px)

=== 各相机重投影误差统计 ===
相机ID	观测点数	平均误差(px)	最大误差(px)
------	--------	----------	----------
0	669		1.138774		3.507408
1	800		1.143470		3.335997
2	963		1.119960		3.558341
3	1236		1.134194		3.828714
4	1384		1.160828		4.002508
5	1273		1.186861		3.400788
6	1419		1.183699		3.492871
7	1307		1.175605		3.275668
8	1283		1.154476		4.030041
9	1125		1.173777		3.302954
10	1300		1.176096		3.561395
11	1171		1.176223		3.541258
12	1203		1.160050		3.402125
13	1249		1.133387		3.659208
14	1225		1.138528		3.633788
15	982		1.135840		3.719180
16	862		1.112004		3.657755
17	814		1.127513		3.426723
18	1163		1.142030		3.467215
19	1553		1.123140		4.273178
20	1930		1.157983		4.526633
21	2123		1.166807		3.760000
22	2463		1.177138		3.750228
23	2486		1.189237		4.736852
24	2481		1.143466		3.686592
25	2536		1.167948		3.438798
26	2507		1.181981		3.609180
27	2472		1.188758		4.423699
28	2441		1.182264		3.603234
29	2483		1.157665		3.327187
30	2484		1.164950		3.781155
31	2492		1.185153		3.670784
32	2397		1.149647		3.426047
33	2044		1.171107		4.462696
34	1734		1.159800		3.820606
35	1407		1.082872		3.349179
36	1043		1.117492		3.077409
37	1350		1.132475		3.575782
38	1859		1.166695		3.566768
39	2214		1.166246		4.206265
40	2403		1.171880		3.918882
41	2410		1.196405		3.748022
42	2455		1.174788		3.738733
43	2530		1.184634		4.019759
44	2513		1.170182		3.729875
45	2486		1.147136		3.860483
46	2473		1.172374		3.837221
47	2486		1.161873		4.006828
48	2418		1.178098		3.607638
49	2485		1.168481		4.618271
50	2481		1.160946		3.773381
51	2049		1.176430		4.057424
52	1771		1.152675		3.946432
53	1659		1.142041		3.543069
54	1354		1.150095		3.438394
55	1600		1.138327		3.702788
56	1752		1.167379		3.827091
57	2157		1.172125		3.782260
58	2391		1.159461		3.774902
59	2429		1.169183		3.842724
60	2378		1.201136		4.486049
61	2437		1.160235		3.625918
62	2497		1.180614		3.512648
63	2457		1.175333		3.901792
64	2586		1.193847		4.473947
65	2575		1.175705		3.773060
66	2599		1.161320		3.512931
67	2607		1.161115		3.698081
68	2451		1.143361		3.820784
69	2056		1.140676		3.687266
70	1671		1.128706		3.619209
71	1263		1.124349		4.584541
72	1122		1.079015		3.740648
73	1567		1.127897		3.590001
74	1955		1.170258		3.543260
75	2223		1.170362		3.765049
76	2526		1.195866		3.869577
77	2529		1.144766		3.896475
78	2509		1.151114		3.560529
79	2513		1.171264		3.767642
80	2552		1.160158		3.700139
81	2525		1.175293		3.726552
82	2513		1.161876		3.560540
83	2503		1.158764		3.792600
84	2502		1.173565		4.141700
85	2492		1.165210		4.019042
86	2346		1.142869		3.652382
87	1984		1.180603		3.698165
88	1868		1.166455		4.111056
89	1289		1.129071		3.762945
90	1075		1.072147		3.739352
91	1441		1.116879		3.608247
92	1756		1.142750		3.263206
93	2055		1.156480		3.373123
94	2242		1.153276		3.855136
95	2224		1.129141		3.476008
96	2380		1.144311		3.556758
97	2379		1.131601		3.560533
98	2219		1.177845		4.151206
99	2356		1.163374		3.976558
100	2341		1.142496		3.640941
101	2263		1.150690		3.291192
102	2241		1.123987		3.727702
103	2374		1.164205		3.684737
104	2180		1.136920		3.452273
105	1911		1.118371		3.378943
106	1699		1.156649		4.433503
107	1365		1.084850		3.220002

从输出的重投影误差报告来看,系统的性能表现优异:

  • 重投影误差:全局光束法平差后的均方根误差(RMSE)仅为 0.925 像素。这意味着计算出的相机位姿和 3D 点坐标在几何上高度自洽,能够精确地解释图像中的特征点位置。
  • 对比分析:这一精度指标与我们在第 19 篇《最小二乘问题详解19:带先验约束的增量式SFM优化与实现》中带先验约束的结果相比差距不大。这证明了“RANSAC + 8点算法 + 非线性优化”这一纯视觉流程在恢复图像间相对几何关系上的有效性。即使没有外部传感器辅助,算法依然能构建出内部一致性极高的几何模型。

另一方面,最终优化后的前三个相机的位姿参数是:

[
  {
    "pitch": -0.05766287195554473,
    "px": 11.408531513538671,
    "py": 9.711222665234468,
    "pz": 0.48165271573651947,
    "roll": 3.1206414891405863,
    "yaw": 3.138861801848
  },
  {
    "pitch": -0.08348148460337527,
    "px": 11.17640395893969,
    "py": 8.763010024814704,
    "pz": 0.06284357759207775,
    "roll": 3.138016071650729,
    "yaw": 3.0552854848438606
  },
  {
    "pitch": -0.023266005538045045,
    "px": 10.333931416822084,
    "py": 8.59484703080245,
    "pz": 0.32661725863531216,
    "roll": -3.1094300937908668,
    "yaw": 3.0341763341055286
  }
]

而作为对比的真实的相机位姿是:

[
  {
    "pitch": 0.009722665505805727,
    "px": 200.78643172534493,
    "py": 214.25507616417832,
    "pz": 258.2814900599498,
    "roll": 0.024034282956425585,
    "yaw": 0.06247329611914964
  },
  {
    "pitch": 0.03636411690376355,
    "px": 200.5850499916218,
    "py": 201.2811957198281,
    "pz": 250.38160856483407,
    "roll": 0.008626373359168276,
    "yaw": -0.02017919864408936
  },
  {
    "pitch": -0.02120212654719572,
    "px": 166.34672474432844,
    "py": 208.88006011663123,
    "pz": 252.61967050777355,
    "roll": -0.030947272405334555,
    "yaw": -0.039678961904428135
  }
]

这说明虽然优化结果的内部精度很高,但这个结果是相对的,而非绝对的。由于纯视觉 SfM 仅依赖 2D-2D 的像素对应关系,系统中不存在任何物理尺度或绝对方向的参考。这导致重建结果具有七参数的不确定性(7 Degrees of Freedom):

  1. 3 个平移自由度:我们无法确定场景在空间中的具体位置(\(X, Y, Z\) 偏移)。例如,运行结果中第一张图的 \(t=[11.4, 9.7, 0.48]\) 与真实数据 \([200.7, 214.2, 258.2]\) 存在巨大的坐标原点差异。
  2. 3 个旋转自由度:我们无法确定场景的绝对朝向(Yaw, Pitch, Roll 偏航、俯仰、滚转)。输出的欧拉角与真实值在数值上完全不同,因为坐标系的轴向是任意选取的。
  3. 1 个尺度自由度:我们无法确定场景的绝对物理大小。在初始化阶段,本质矩阵分解出的平移向量 \(\mathbf{t}\)尺度归一化的(即长度被强制为 1)。这意味着,无论真实场景的基线是 1 米还是 100 米,算法初始化时都将其视为“1 个单位”。因此,重建结果与真实数据之间缺失了一个物理尺度因子(Scale Factor)。输出的平移向量(如 \([11.4, 9.7, 0.48]\))仅代表相对方向,其模长仅代表重建系统内部的“任意单位”,与真实物理距离(如 \([200.7, 214.2, 258.2]\))无法直接对应。

总而言之,纯视觉 SfM 重建出的是一个欧氏空间下的“相似变换”模型。它完美地保留了场景的几何形状(角度、比例),但丢失了绝对的位置、朝向和尺度。如果需要将这个“自由网”转换到真实世界的地理坐标系中,必须依赖外部先验(如控制点)或物理约束(如双目视觉提供的绝对尺度)。

上一篇 | 目录 | 下一篇
代码存档