// This file is part of OpenCV project. // It is subject to the license terms in the LICENSE file found in the top-level directory // of this distribution and at http://opencv.org/license.html. // Copyright (C) 2026, BigVision LLC, all rights reserved. // Third party copyrights are property of their respective owners. #include "../precomp.hpp" #include "vo_impl.hpp" #include #include namespace cv { namespace slam { namespace { const char* stateName(OdometryState s) { switch (s) { case NOT_INITIALIZED: return "NOT_INITIALIZED"; case INITIALIZING: return "INITIALIZING"; case TRACKING: return "TRACKING"; } return "NOT_INITIALIZED"; } String joinPath(const String& dir, const String& name) { if (dir.empty()) return name; char last = dir.back(); if (last == '/' || last == '\\') return dir + name; return dir + "/" + name; } } // anonymous namespace // Factory VisualOdometry::VisualOdometry() = default; VisualOdometry::~VisualOdometry() = default; Ptr VisualOdometry::create( const Ptr& detector, const Ptr& matcher, const String& imagesFolder, const String& outputFolder, InputArray cameraMatrix, InputArray distCoeffs, const OdometryParams& params) { CV_Assert(detector && "VisualOdometry::create: detector must not be null"); CV_Assert(matcher && "VisualOdometry::create: matcher must not be null"); Mat K = cameraMatrix.getMat(); CV_Assert(!K.empty() && K.rows == 3 && K.cols == 3); Mat dist = distCoeffs.empty() ? Mat() : distCoeffs.getMat(); return makePtr( detector, matcher, imagesFolder, outputFolder, K, dist, params); } // Constructor VisualOdometryImpl::VisualOdometryImpl( const Ptr& detector, const Ptr& matcher, const String& imagesFolder, const String& outputFolder, const Mat& cameraMatrix, const Mat& distCoeffs, const OdometryParams& params) : detector(detector), matcher(matcher), params(params), imagesFolder(imagesFolder), outputFolder(outputFolder) { cameraMatrix.convertTo(K, CV_64F); if (!distCoeffs.empty()) distCoeffs.convertTo(dist, CV_64F); } // reset / processFrame void VisualOdometryImpl::reset() { state = NOT_INITIALIZED; lastPoseCw = Matx44d::eye(); refFrame = Frame(); lastKf = nullptr; framesSinceKf = 0; lastKfInliers = 0; velocity = Matx44d::eye(); hasVelocity = false; prevFrame = Frame(); hasPrevFrame = false; lastEvent.clear(); poseFilenames.clear(); map.clear(); } bool VisualOdometryImpl::processFrame(InputArray image) { CV_INSTRUMENT_REGION(); if (image.empty()) return false; lastEvent.clear(); Frame cur; extractFeatures(image, cur); if (cur.keypoints.empty() || cur.descriptors.empty()) return false; cur.mapPoints.assign(cur.keypoints.size(), nullptr); cur.outliers.assign(cur.keypoints.size(), false); cur.buildGrid(); switch (state) { case NOT_INITIALIZED: refFrame = cur; state = INITIALIZING; return false; case INITIALIZING: return bootstrap(cur); case TRACKING: return track(cur); } return false; } // Feature extraction void VisualOdometryImpl::extractFeatures(InputArray image, Frame& out) const { Mat img = image.getMat(); out.imageSize = img.size(); out.keypoints.clear(); // Detect and compute on the original image (color/grey is up to the detector). detector->detectAndCompute(img, noArray(), out.keypoints, out.descriptors); // Store a greyscale copy for the optical-flow fallback. if (img.channels() > 1) cvtColor(img, out.image, COLOR_BGR2GRAY); else out.image = img.clone(); // Pre-compute undistorted pixel coordinates used by every stage. if (!out.keypoints.empty()) { std::vector raw; raw.reserve(out.keypoints.size()); for (const auto& kp : out.keypoints) raw.push_back(kp.pt); if (!dist.empty()) undistortPoints(raw, out.undistKpts, K, dist, noArray(), K); else out.undistKpts = raw; } } // Frame matching helper void VisualOdometryImpl::matchFrames( const std::vector& qKp, const Mat& qDesc, Size qSz, const std::vector& tKp, const Mat& tDesc, Size tSz, std::vector& matches) const { matches.clear(); if (qDesc.empty() || tDesc.empty()) return; if (qKp.empty() || tKp.empty()) return; LightGlueMatcher* lg = dynamic_cast(matcher.get()); if (lg) { Mat qk((int)qKp.size(), 2, CV_32F); for (size_t i = 0; i < qKp.size(); ++i) { qk.at((int)i,0) = qKp[i].pt.x; qk.at((int)i,1) = qKp[i].pt.y; } Mat tk((int)tKp.size(), 2, CV_32F); for (size_t i = 0; i < tKp.size(); ++i) { tk.at((int)i,0) = tKp[i].pt.x; tk.at((int)i,1) = tKp[i].pt.y; } lg->setPairInfo(qk, tk, qSz, tSz); } matcher->match(qDesc, tDesc, matches); } // Batch run() bool VisualOdometryImpl::run() { CV_INSTRUMENT_REGION(); if (imagesFolder.empty()) { CV_LOG_ERROR(NULL, "VisualOdometry::run: imagesFolder is empty"); return false; } std::vector allFiles; try { cv::glob(imagesFolder, allFiles, false); } catch (const cv::Exception& e) { CV_LOG_ERROR(NULL, "VisualOdometry::run: glob failed: " << e.what()); return false; } std::vector imgFiles; imgFiles.reserve(allFiles.size()); for (const auto& f : allFiles) if (cv::haveImageReader(f)) imgFiles.push_back(f); std::sort(imgFiles.begin(), imgFiles.end()); if (imgFiles.empty()) { CV_LOG_WARNING(NULL, "VisualOdometry::run: no images in " << imagesFolder); return false; } std::ofstream log; if (!outputFolder.empty()) { cv::utils::fs::createDirectories(outputFolder); log.open(joinPath(outputFolder, "vo.log").c_str()); } auto logln = [&](const String& s) { if (log.is_open()) log << s << "\n"; }; logln("[INFO] optimizer = reprojection inlier check"); logln(String("[INFO] images_folder = ") + imagesFolder); logln(String("[INFO] output_folder = ") + outputFolder); { std::ostringstream ss; ss << "[INFO] found " << imgFiles.size() << " image(s)"; logln(ss.str()); } reset(); int nEmitted = 0; size_t prevTrajLen = 0; String refFilename; for (size_t i = 0; i < imgFiles.size(); ++i) { Mat img = imread(imgFiles[i]); if (img.empty()) { std::ostringstream ss; ss << "[FRAME " << i << "] file=" << imgFiles[i] << " ERROR: imread failed"; logln(ss.str()); continue; } OdometryState before = state; bool emitted = processFrame(img); OdometryState after = state; if (emitted) ++nEmitted; // Track which input image maps to each trajectory pose. if (before == NOT_INITIALIZED || (before == TRACKING && after == INITIALIZING)) refFilename = imgFiles[i]; const size_t added = map.trajectory().size() - prevTrajLen; if (added == 1) poseFilenames.push_back(imgFiles[i]); else if (added == 2) { poseFilenames.push_back(refFilename); poseFilenames.push_back(imgFiles[i]); } prevTrajLen = map.trajectory().size(); std::ostringstream ss; ss << "[FRAME " << i << "] file=" << imgFiles[i] << " state=" << stateName(before); if (before != after) ss << "->" << stateName(after); ss << " emitted=" << (emitted ? "yes" : "no") << " keyframes=" << map.numKeyframes() << " map_points=" << map.numMapPoints(); if (!lastEvent.empty()) ss << " [" << lastEvent << "]"; if (emitted) { Point3d C = detail::cameraCenterWorld(lastPoseCw); ss << " C=(" << C.x << "," << C.y << "," << C.z << ")"; } logln(ss.str()); } if (!outputFolder.empty()) { writeTrajectoryText(joinPath(outputFolder, "trajectory.txt")); writeTrajectoryBin (joinPath(outputFolder, "trajectory.bin")); writeMapPoints (joinPath(outputFolder, "map_points.txt")); writeKeypoints (joinPath(outputFolder, "keypoints.txt")); writeImagesTxt (joinPath(outputFolder, "images.txt")); std::ostringstream ss; ss << "[INFO] run complete: frames=" << imgFiles.size() << " emitted=" << nEmitted << " keyframes=" << map.numKeyframes() << " map_points=" << map.numMapPoints(); logln(ss.str()); logln("[INFO] wrote trajectory.txt, trajectory.bin, map_points.txt, " "keypoints.txt, images.txt"); } return nEmitted > 0; } // IO helpers void VisualOdometryImpl::writeTrajectoryText(const String& path) const { std::ofstream f(path.c_str()); if (!f.is_open()) { CV_LOG_WARNING(NULL, "writeTrajectoryText: cannot open " << path); return; } f << "# Per-frame camera center in world coordinates.\n# Columns: Cx Cy Cz\n"; f.setf(std::ios::scientific); f.precision(9); for (const auto& T : map.trajectory()) { Point3d C = detail::cameraCenterWorld(T); f << C.x << " " << C.y << " " << C.z << "\n"; } } void VisualOdometryImpl::writeTrajectoryBin(const String& path) const { std::ofstream f(path.c_str(), std::ios::binary); if (!f.is_open()) { CV_LOG_WARNING(NULL, "writeTrajectoryBin: cannot open " << path); return; } const char magic[4] = {'V','O','T','R'}; f.write(magic, 4); int32_t version = 1; f.write(reinterpret_cast(&version), sizeof(int32_t)); int32_t n = (int32_t)map.trajectory().size(); f.write(reinterpret_cast(&n), sizeof(int32_t)); for (const auto& T : map.trajectory()) { double buf[16]; for (int r = 0; r < 4; ++r) for (int c = 0; c < 4; ++c) buf[r*4+c] = T(r,c); f.write(reinterpret_cast(buf), sizeof(buf)); } } void VisualOdometryImpl::writeMapPoints(const String& path) const { std::ofstream f(path.c_str()); if (!f.is_open()) { CV_LOG_WARNING(NULL, "writeMapPoints: cannot open " << path); return; } f << "# Map points in world coordinates.\n# Columns: id X Y Z n_observations\n"; f.setf(std::ios::scientific); f.precision(9); for (MapPoint* mp : map.mapPoints()) { if (!mp || mp->bad) continue; f << mp->id << " " << mp->pos.x << " " << mp->pos.y << " " << mp->pos.z << " " << mp->observations.size() << "\n"; } } void VisualOdometryImpl::writeKeypoints(const String& path) const { std::ofstream f(path.c_str()); if (!f.is_open()) { CV_LOG_WARNING(NULL, "writeKeypoints: cannot open " << path); return; } f << "# Per-keyframe keypoints.\n" << "# Block header: KF kf_id Cx Cy Cz n_keypoints\n" << "# Followed by n_keypoints rows: kpIdx x y size angle response octave mpId\n" << "# mpId = -1 if the keypoint has no map point.\n"; f.setf(std::ios::fixed); f.precision(6); for (KeyFrame* kf : map.keyframes()) { if (!kf) continue; Point3d C = detail::cameraCenterWorld(kf->poseCw); f << "KF " << kf->id << " " << C.x << " " << C.y << " " << C.z << " " << kf->keypoints.size() << "\n"; for (size_t i = 0; i < kf->keypoints.size(); ++i) { const KeyPoint& kp = kf->keypoints[i]; int mpId = -1; if (i < kf->mapPoints.size() && kf->mapPoints[i]) mpId = kf->mapPoints[i]->id; f << i << " " << kp.pt.x << " " << kp.pt.y << " " << kp.size << " " << kp.angle << " " << kp.response << " " << kp.octave << " " << mpId << "\n"; } } } // Shepperd's method: numerically-stable R → unit quaternion (qw, qx, qy, qz). static void rotMatToQuat(const Matx33d& R, double& qw, double& qx, double& qy, double& qz) { const double tr = R(0,0) + R(1,1) + R(2,2); if (tr > 0.0) { double s = std::sqrt(tr + 1.0) * 2.0; qw = 0.25 * s; qx = (R(2,1) - R(1,2)) / s; qy = (R(0,2) - R(2,0)) / s; qz = (R(1,0) - R(0,1)) / s; } else if (R(0,0) > R(1,1) && R(0,0) > R(2,2)) { double s = std::sqrt(1.0 + R(0,0) - R(1,1) - R(2,2)) * 2.0; qw = (R(2,1) - R(1,2)) / s; qx = 0.25 * s; qy = (R(0,1) + R(1,0)) / s; qz = (R(0,2) + R(2,0)) / s; } else if (R(1,1) > R(2,2)) { double s = std::sqrt(1.0 + R(1,1) - R(0,0) - R(2,2)) * 2.0; qw = (R(0,2) - R(2,0)) / s; qx = (R(0,1) + R(1,0)) / s; qy = 0.25 * s; qz = (R(1,2) + R(2,1)) / s; } else { double s = std::sqrt(1.0 + R(2,2) - R(0,0) - R(1,1)) * 2.0; qw = (R(1,0) - R(0,1)) / s; qx = (R(0,2) + R(2,0)) / s; qy = (R(1,2) + R(2,1)) / s; qz = 0.25 * s; } } static String basenameOf(const String& path) { const size_t slash = path.find_last_of("/\\"); return (slash == String::npos) ? path : path.substr(slash + 1); } void VisualOdometryImpl::writeImagesTxt(const String& path) const { std::ofstream f(path.c_str()); if (!f.is_open()) { CV_LOG_WARNING(NULL, "writeImagesTxt: cannot open " << path); return; } const auto& traj = map.trajectory(); f << "# Image list with two lines of data per image:\n" << "# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME\n" << "# POINTS2D[] as (X, Y, POINT3D_ID)\n" << "# Number of images: " << traj.size() << ", mean observations per image: 0.0\n"; f.setf(std::ios::fixed); f.precision(6); for (size_t i = 0; i < traj.size(); ++i) { const Matx44d& T = traj[i]; Matx33d R; for (int r = 0; r < 3; ++r) for (int c = 0; c < 3; ++c) R(r,c) = T(r,c); double qw, qx, qy, qz; rotMatToQuat(R, qw, qx, qy, qz); const String name = (i < poseFilenames.size()) ? basenameOf(poseFilenames[i]) : (String("pose_") + std::to_string(i)); f << i << " " << qw << " " << qx << " " << qy << " " << qz << " " << T(0,3) << " " << T(1,3) << " " << T(2,3) << " " << 1 << " " << name << "\n\n"; } } }} // namespace cv::slam