Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Implementing the Kannala-Brandt Fisheye Camera Model in C++

Tech Oct 8 15

While studying the ORB-SLAM3 source code, I recreated and refactored its camera model implementation. A key discovery was that despite both pinhole and fisheye cameras having distortion parameters, the fisheye model cannot use cv::undistort for correction. After visualizing the depth-normalized plane of a fisheye camera, it became clear that fisheye images inherently don't require conventional undistortion.

This work is based on the reference: "A generic camera model and calibration method for conventional, wide-angle, and fish-eye lenses".

Camera Base Class

The utility header utils/glog.hpp wraps the glog library and defines the ASSERT(expr, msg) macro. The base class Base is initialized with image size, intrinsics, and distortion coefficients.

#ifndef ZJCV__CAMERA__BASE_HPP
#define ZJCV__CAMERA__BASE_HPP

#include <eigen>
#include <opencv2>
#include <sophus>
#include "utils/glog.hpp"

namespace camera {

typedef std::vector<float> Vectorf;
typedef std::vector<cv::Point2f> VectorPt2f;
typedef std::vector<cv::KeyPoint> VectorKp;

enum CameraType { PINHOLE, KANNALA_BRANDT };

class Base {
protected:
    cv::Size mImgSize;
    Vectorf mvParam;
    cv::Mat mMap1, mMap2;  // distortion correction maps
public:
    Sophus::SE3f T_cam_imu;
    typedef std::shared_ptr<Base> Ptr;

    explicit Base(const cv::Size imgSize, const Vectorf &intrinsics,
                  const Vectorf &distCoeffs,
                  const Sophus::SE3f &T_cam_imu = Sophus::SE3f())
        : mImgSize(imgSize), mvParam(intrinsics), T_cam_imu(T_cam_imu) {
        ASSERT(intrinsics.size() == 4, "Intrinsics size must be 4");
        mvParam.insert(mvParam.end(), distCoeffs.begin(), distCoeffs.end());
    }

    Base(const Base &) = delete;
    virtual CameraType get_type() const = 0;

    // Parameter access
    inline void set_param(int i, float value, bool safe = false) {
        if (!safe) LOG(WARNING) << "Unsafe set_param: " << i << " = " << value;
        mvParam[i] = value;
    }
    inline float get_param(int i) const { return mvParam[i]; }
    inline size_t get_param_size() const { return mvParam.size(); }
    Vectorf get_distcoeffs() const {
        return {mvParam.begin() + 4, mvParam.end()};
    }

    // Intrinsic matrix K
#define GETK(vp, K) \
    (K << vp[0], 0.f, vp[2], 0.f, vp[1], vp[3], 0.f, 0.f, 1.f)

    virtual cv::Mat getK() const {
        return GETK(mvParam, cv::Mat_<float>(3, 3));
    }
    virtual Eigen::Matrix3f getK_eig() const {
        return GETK(mvParam, Eigen::Matrix3f()).finished();
    }

    // 3D -> 2D projection
    virtual cv::Point2f project(const Eigen::Vector3f &v3D) const = 0;
    virtual Eigen::Vector2d project(const Eigen::Vector3d &v3D) const = 0;
    virtual Eigen::Vector2f project_eig(const cv::Point3f &p3D) const = 0;

    // 2D -> 3D unprojection
    virtual Eigen::Vector3f unproject(const cv::Point2f &p2D) const = 0;

    // Distortion removal
    virtual void undistort(const cv::Mat &src, cv::Mat &dst) const = 0;
    virtual void undistort(const VectorPt2f &src, VectorPt2f &dst) const = 0;
    virtual void undistort(const VectorKp &src, VectorKp &dst) const {
        VectorPt2f pts;
        for (const auto &kp : src) pts.push_back(kp.pt);
        undistort(pts, pts);
        for (size_t i = 0; i < src.size(); ++i) dst[i].pt = pts[i];
    }

    // Visualize normalized plane (z=1)
    void draw_normalized_plane(const cv::Mat &src, cv::Mat &dst) const;
};

}
#endif
</sophus></opencv2></eigen>

Fisheye Camera Model Implementation

To handle C++ function polymorphism efficiently without code duplication, macros are used to encapsulate the projectino and unprojection logic.

#ifndef ZJCV__CAMERA__KANNALA_BRANDT_HPP
#define ZJCV__CAMERA__KANNALA_BRANDT_HPP

#include "base.hpp"

namespace camera {

#define KANNALA_BRANDT_MAX_FOV M_PI  // maximum field of view (180°)

#define KANNALA_BRANDT_PROJECT(vp, x, y, z) \
  float R = this->computeR(atan2f(hypot(x, y), z)); \
  float psi = atan2f(y, x); \
  return {vp[0] * R * cosf(psi) + vp[2], vp[1] * R * sinf(psi) + vp[3]};

#define KANNALA_BRANDT_UNPROJECT_PRECISION 1e-6

#define KANNALA_BRANDT_UNPROJECT(cache, p2D) \
  cv::Mat wxy; \
  cv::getRectSubPix(cache, {1, 1}, p2D, wxy); \
  return {wxy.at<float>(0), wxy.at<float>(1), 1};

class KannalaBrandt : public Base {
protected:
    cv::Mat mUnprojectCache;
    void make_unproject_cache();
public:
    typedef std::shared_ptr<KannalaBrandt> Ptr;
    using Base::undistort;

    explicit KannalaBrandt(const cv::Size imgSize, const Vectorf &intrinsics,
                           const Vectorf &distCoeffs,
                           const Sophus::SE3f &T_cam_imu = Sophus::SE3f())
        : Base(imgSize, intrinsics, distCoeffs, T_cam_imu),
          mUnprojectCache(mImgSize, CV_32FC3) {
        ASSERT(distCoeffs.size() == 4, "Distortion coefficients size must be 4");
        make_unproject_cache();
    }

    KannalaBrandt(const KannalaBrandt &) = delete;
    CameraType get_type() const override { return CameraType::KANNALA_BRANDT; }

    // Project 3D point to 2D pixel
    float computeR(float theta) const;
    cv::Point2f project(const Eigen::Vector3f &v3D) const override {
        KANNALA_BRANDT_PROJECT(mvParam, v3D[0], v3D[1], v3D[2]);
    }
    Eigen::Vector2d project(const Eigen::Vector3d &v3D) const override {
        KANNALA_BRANDT_PROJECT(mvParam, v3D[0], v3D[1], v3D[2]);
    }
    Eigen::Vector2f project_eig(const cv::Point3f &p3D) const override {
        KANNALA_BRANDT_PROJECT(mvParam, p3D.x, p3D.y, p3D.z);
    }

    // Unproject 2D pixel to 3D normalized ray
    float solveWZ(float wx, float wy, size_t iterations = 10) const;
    Eigen::Vector3f unproject(const cv::Point2f &p2D) const override {
        KANNALA_BRANDT_UNPROJECT(mUnprojectCache, p2D);
    }

    // Undistortion: for Kannala-Brandt, images are already in a suitable form
    void undistort(const cv::Mat &src, cv::Mat &dst) const override {
        if (src.data != dst.data) dst = src.clone();
    }
    void undistort(const VectorPt2f &src, VectorPt2f &dst) const override {
        if (src.data() != dst.data()) dst = src;
    }
    void undistort(const VectorKp &src, VectorKp &dst) const override {
        if (src.data() != dst.data()) dst = src;
    }
};

}
#endif

Projection (3D World → 2D Pixel)

The projection function computes the radial distance R from the incident angle θ using a polynomial distortion model.

float KannalaBrandt::computeR(float theta) const {
    float theta2 = theta * theta;
    return theta + theta2 * (mvParam[4] + theta2 *
           (mvParam[5] + theta2 * (mvParam[6] + theta2 * mvParam[7])));
}

Unprojection (2D Pixel → 3D Ray)

For a given pixel, we compute wx, wy from normalized coordinates, then solve for wz using gradient descent to invert the projection.

void KannalaBrandt::make_unproject_cache() {
    float wx, wy, wz;
    for (int r = 0; r < mImgSize.height; ++r) {
        wy = (r - mvParam[3]) / mvParam[1];
        for (int c = 0; c < mImgSize.width; ++c) {
            wx = (c - mvParam[2]) / mvParam[0];
            wz = this->solveWZ(wx, wy);
            mUnprojectCache.at<cv::Vec3f>(r, c) = {wx / wz, wy / wz, 1};
        }
    }
}

float KannalaBrandt::solveWZ(float wx, float wy, size_t iterations) const {
    float wz = 1.f;
    float R = hypot(wx, wy);
    if (R > KANNALA_BRANDT_UNPROJECT_PRECISION) {
        float theta = KANNALA_BRANDT_MAX_FOV;
        if (R < this->computeR(theta)) {
            // Minimize loss: (poly(theta) - R)^2
            int i = 0;
            float e;
            for (; i < iterations; i++) {
                float theta2 = theta * theta;
                float theta4 = theta2 * theta2;
                float theta6 = theta4 * theta2;
                float theta8 = theta6 * theta2;
                float k0_theta2 = mvParam[4] * theta2;
                float k1_theta4 = mvParam[5] * theta4;
                float k2_theta6 = mvParam[6] * theta6;
                float k3_theta8 = mvParam[7] * theta8;
                e = theta * (1 + k0_theta2 + k1_theta4 + k2_theta6 + k3_theta8) - R;
                if (abs(e) < R * KANNALA_BRANDT_UNPROJECT_PRECISION) break;
                // Gradient descent: g = e / poly'(theta)
                float deriv = 1 + 3*k0_theta2 + 5*k1_theta4 + 7*k2_theta6 + 9*k3_theta8;
                theta -= e / deriv;
            }
            if (i == iterations)
                LOG(WARNING) << "solveWZ(" << wx << ", " << wy << "): relative error " << abs(e) / R;
        }
        wz = R / tanf(theta);
    }
    return wz;
}

Visualizing the Depth-Normalized Plane

The depth-normalized plane projects world points onto z = 1, effectively generating a remapped image. The algorithm first computes the unprojected boundaries of the image, then projects each normalized point back to pixel coordinates.

void Base::draw_normalized_plane(const cv::Mat &src, cv::Mat &dst) const {
    undistort(src, dst);
    cv::Mat npMap1 = cv::Mat(mImgSize, CV_32FC1);
    cv::Mat npMap2 = npMap1.clone();

    // Compute normalized plane boundaries (barrel distortion)
    float W = mImgSize.width - 1, H = mImgSize.height - 1;
    float x = this->unproject({0, H / 2})[0];
    float y = this->unproject({W / 2, 0})[1];
    float w = this->unproject({W, H / 2})[0] - x;
    float h = this->unproject({W / 2, H})[1] - y;
    LOG(INFO) << "Normalized plane: " << cv::Vec4f(x, y, x + w, y + h);

    // Build distortion correction mapping
    for (int r = 0; r < H; ++r) {
        for (int c = 0; c < W; ++c) {
            cv::Point2f p2D = this->project(
                Eigen::Vector3f(w * c / W + x, h * r / H + y, 1));
            npMap1.at<float>(r, c) = p2D.x;
            npMap2.at<float>(r, c) = p2D.y;
        }
    }
    cv::remap(dst, dst, npMap1, npMap2, cv::INTER_LINEAR);
}

Experimental Results with TUM-VI Dataset

Testing was conducted using the TUM-VI dataset with a Kannala-Brandt camera having the following parameters:

  • Resolution: [512, 512]
  • Intrinsics: [190.97848, 190.97331, 254.93171, 256.89744]
  • Distortion coefficients: [0.0034824, 0.0007150, -0.0020532, 0.0002029]

The following code snippet (using custom auxiliary libraries) demonstrates loading and visualizing a fisheye image:

void fisheye_test() {
    // Load TUM-VI camera parameters
    dataset::TumVI tumvi("/home/workbench/data/dataset-corridor4_512_16/dso");
    YAML::Node cfg = tumvi.loadCfg();
    auto cam = camera::fromYAML<camera::KannalaBrandt>(cfg["cam0"]);

    // Load image list and read first image
    dataset::Timestamps vTimestamps;
    dataset::Filenames vFilename;
    tumvi.loadImage(vTimestamps, vFilename);
    cv::Mat img = cv::imread(vFilename[0]), dst1;

    // Show original and normalized plane images
    cv::imshow("Original", img);
    cam->draw_normalized_plane(img, img);
    cv::imshow("NormalizedPlane", img);
    cv::waitKey(0);
}

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.