diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 65b620505d8ce22b9e84c5c6785b76896a83deef..da5fd16da6596671e3441b510943609e8881c50b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -44,4 +44,5 @@ windows:
     - devenv ftl.utu.fi.sln /build Release
     - rmdir /q /s "%DEPLOY_DIR%/%CI_COMMIT_REF_SLUG%"
     - mkdir "%DEPLOY_DIR%/%CI_COMMIT_REF_SLUG%"
-    - 'copy "applications\vision\Release\ftl-vision.exe" "%DEPLOY_DIR%\%CI_COMMIT_REF_SLUG%"'
\ No newline at end of file
+    - 'copy "applications\vision\Release\ftl-vision.exe" "%DEPLOY_DIR%\%CI_COMMIT_REF_SLUG%"'
+    - 'copy "applications\calibration\Release\ftl-calibrate.exe" "%DEPLOY_DIR%\%CI_COMMIT_REF_SLUG%"'
\ No newline at end of file
diff --git a/CMakeLists.txt b/CMakeLists.txt
index efb6f222f6593725042c08e3d74b4a00a611ca8f..dabffe4e20021aa110011ac312973528a653252b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -163,6 +163,7 @@ add_subdirectory(components/common/cpp)
 add_subdirectory(components/net)
 add_subdirectory(components/rgbd-sources)
 add_subdirectory(components/control/cpp)
+add_subdirectory(applications/calibration)
 
 if (BUILD_RENDERER)
 	add_subdirectory(components/renderers)
diff --git a/applications/calibration/CMakeLists.txt b/applications/calibration/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0efa6e95c7c66e4480e2965e0cccffae39d5cb09
--- /dev/null
+++ b/applications/calibration/CMakeLists.txt
@@ -0,0 +1,15 @@
+set(CALIBSRC
+	src/main.cpp
+	src/lens.cpp
+	src/stereo.cpp
+	src/align.cpp
+	src/common.cpp
+)
+
+add_executable(ftl-calibrate ${CALIBSRC})
+
+target_include_directories(ftl-calibrate PRIVATE src)
+
+target_link_libraries(ftl-calibrate ftlcommon Threads::Threads ${OpenCV_LIBS})
+
+
diff --git a/applications/calibration/src/align.cpp b/applications/calibration/src/align.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..bbf6f0f2e475457bb0ae3d6ee9de683deb67b105
--- /dev/null
+++ b/applications/calibration/src/align.cpp
@@ -0,0 +1,398 @@
+#include "align.hpp"
+
+#include <loguru.hpp>
+
+#include <opencv2/core.hpp>
+#include <opencv2/core/utility.hpp>
+#include <opencv2/imgproc.hpp>
+#include <opencv2/calib3d.hpp>
+#include <opencv2/imgcodecs.hpp>
+#include <opencv2/videoio.hpp>
+#include <opencv2/highgui.hpp>
+
+using std::map;
+using std::string;
+using cv::Mat;
+using std::vector;
+using cv::Point2f;
+using cv::Size;
+
+struct Rec4f {
+	float left;
+	float right;
+	float top;
+	float bottom;
+};
+
+bool loadIntrinsicsMap(const std::string &ifile, const cv::Size &imageSize, Mat &map1, Mat &map2, Mat &cameraMatrix, float scale) {
+    using namespace cv;
+
+    FileStorage fs;
+
+    // reading intrinsic parameters
+        fs.open((ifile).c_str(), FileStorage::READ);
+        if (!fs.isOpened()) {
+            LOG(WARNING) << "Could not open intrinsics file : " << ifile;
+            return false;
+        }
+        
+        LOG(INFO) << "Intrinsics from: " << ifile;
+
+
+    Mat D1;
+    fs["M"] >> cameraMatrix;
+    fs["D"] >> D1;
+
+    //cameraMatrix *= scale;
+
+	initUndistortRectifyMap(cameraMatrix, D1, Mat::eye(3,3, CV_64F), cameraMatrix, imageSize, CV_16SC2,
+    		map1, map2);
+
+    return true;
+}
+
+inline bool hasOption(const map<string, string> &options, const std::string &opt) {
+    return options.find(opt) != options.end();
+}
+
+inline std::string getOption(map<string, string> &options, const std::string &opt) {
+    auto str = options[opt];
+    return str.substr(1,str.size()-2);
+}
+
+static const float kPI = 3.14159f;
+
+static float calculateZRotation(const vector<Point2f> &points, Size &boardSize) {
+    Point2f tl = points[boardSize.width * (boardSize.height / 2)];
+    Point2f tr = points[boardSize.width * (boardSize.height / 2) + boardSize.width-1];
+
+    float dx = tr.x - tl.x;
+    float dy = tr.y - tl.y;
+    float angle = atan2(dy,  dx) * (180.0f / kPI);
+    return angle;
+}
+
+static Point2f parallaxDistortion(const vector<Point2f> &points, Size &boardSize) {
+    Point2f tl = points[0];
+    Point2f tr = points[boardSize.width-1];
+    Point2f bl = points[(boardSize.height-1)*boardSize.width];
+    Point2f br = points[points.size()-1];
+
+    float dx1 = tr.x - tl.x;
+    float dx2 = br.x - bl.x;
+    float ddx = dx1 - dx2;
+
+    float dy1 = bl.y - tl.y;
+    float dy2 = br.y - tr.y;
+    float ddy = dy1 - dy2;
+
+    return Point2f(ddx, ddy);
+}
+
+static float distanceTop(const Mat &camMatrix, const vector<Point2f> &points, Size &boardSize, float squareSize) {
+    Point2f tl = points[0];
+    Point2f tr = points[boardSize.width-1];
+
+    float pixSize = tr.x - tl.x;
+    float mmSize = boardSize.width * squareSize;
+    float focal = camMatrix.at<double>(0,0);
+
+    return ((mmSize / pixSize) * focal) / 1000.0f;
+}
+
+static float distanceBottom(const Mat &camMatrix, const vector<Point2f> &points, Size &boardSize, float squareSize) {
+    Point2f bl = points[(boardSize.height-1)*boardSize.width];
+    Point2f br = points[points.size()-1];
+
+    float pixSize = br.x - bl.x;
+    float mmSize = boardSize.width * squareSize;
+    float focal = camMatrix.at<double>(0,0);
+
+    return ((mmSize / pixSize) * focal) / 1000.0f;
+}
+
+static float distanceLeft(const Mat &camMatrix, const vector<Point2f> &points, Size &boardSize, float squareSize) {
+    Point2f bl = points[(boardSize.height-1)*boardSize.width];
+    Point2f tl = points[0];
+
+    float pixSize = bl.y - tl.y;
+    float mmSize = boardSize.height * squareSize;
+    float focal = camMatrix.at<double>(0,0);
+
+    return ((mmSize / pixSize) * focal) / 1000.0f;
+}
+
+static float distanceRight(const Mat &camMatrix, const vector<Point2f> &points, Size &boardSize, float squareSize) {
+    Point2f tr = points[boardSize.width-1];
+    Point2f br = points[points.size()-1];
+
+    float pixSize = br.y - tr.y;
+    float mmSize = boardSize.height * squareSize;
+    float focal = camMatrix.at<double>(0,0);
+
+    return ((mmSize / pixSize) * focal) / 1000.0f;
+}
+
+static Rec4f distances(const Mat &camMatrix, const vector<Point2f> &points, Size &boardSize, float squareSize) {
+	return {
+		-distanceLeft(camMatrix, points, boardSize, squareSize),
+		-distanceRight(camMatrix, points, boardSize, squareSize),
+		-distanceTop(camMatrix, points, boardSize, squareSize),
+		-distanceBottom(camMatrix, points, boardSize, squareSize)
+	};
+}
+
+static float distance(const Mat &camMatrix, const vector<Point2f> &points, Size &boardSize, float squareSize) {
+    Point2f tl = points[boardSize.width * (boardSize.height / 2)];
+    Point2f tr = points[boardSize.width * (boardSize.height / 2) + boardSize.width-1];
+
+    float pixSize = tr.x - tl.x;
+    float mmSize = boardSize.width * squareSize;
+    float focal = camMatrix.at<double>(0,0);
+
+    return ((mmSize / pixSize) * focal) / 1000.0f;
+}
+
+static Point2f diffY(const vector<Point2f> &pointsA, const vector<Point2f> &pointsB, Size &boardSize) {
+    Point2f tlA = pointsA[boardSize.width * (boardSize.height / 2)];
+    Point2f trA = pointsA[boardSize.width * (boardSize.height / 2) + boardSize.width-1];
+
+    Point2f tlB = pointsB[boardSize.width * (boardSize.height / 2)];
+    Point2f trB = pointsB[boardSize.width * (boardSize.height / 2) + boardSize.width-1];
+
+    float d1 = tlA.y - tlB.y;
+    float d2 = trA.y - trB.y;
+
+    return Point2f(d1,d2);
+}
+
+static const float kDistanceThreshold = 0.005f;
+
+
+static void showAnaglyph(const Mat &frame_l, const Mat &frame_r, Mat &img3d) {
+    using namespace cv;
+
+    float data[] = {0.299f, 0.587f, 0.114f, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.299, 0.587, 0.114};
+    Mat m(2, 9, CV_32FC1, data);
+
+    //Mat img3d;
+
+    img3d = Mat(frame_l.size(), CV_8UC3);
+  
+    for (int y=0; y<img3d.rows; y++) {
+        unsigned char *row3d = img3d.ptr(y);
+        const unsigned char *rowL = frame_l.ptr(y);
+        const unsigned char *rowR = frame_r.ptr(y);
+
+        for (int x=0; x<img3d.cols*3; x+=3) {
+            const uchar lb = rowL[x+0];
+            const uchar lg = rowL[x+1];
+            const uchar lr = rowL[x+2];
+            const uchar rb = rowR[x+0];
+            const uchar rg = rowR[x+1];
+            const uchar rr = rowR[x+2];
+
+            row3d[x+0] = lb*m.at<float>(0,6) + lg*m.at<float>(0,7) + lr*m.at<float>(0,8) + rb*m.at<float>(1,6) + rg*m.at<float>(1,7) + rr*m.at<float>(1,8);
+            row3d[x+1] = lb*m.at<float>(0,3) + lg*m.at<float>(0,4) + lr*m.at<float>(0,5) + rb*m.at<float>(1,3) + rg*m.at<float>(1,4) + rr*m.at<float>(1,5);
+            row3d[x+2] = lb*m.at<float>(0,0) + lg*m.at<float>(0,1) + lr*m.at<float>(0,2) + rb*m.at<float>(1,0) + rg*m.at<float>(1,1) + rr*m.at<float>(1,2);
+        }
+    }
+
+    //imshow("Anaglyph", img3d);
+    //return img3d;
+}
+
+void ftl::calibration::align(map<string, string> &opt) {
+    using namespace cv;
+
+    float squareSize = 36.0f;
+
+    VideoCapture camA(0);
+    VideoCapture camB(1);
+
+    if (!camA.isOpened() || !camB.isOpened()) {
+        LOG(ERROR) << "Could not open a camera device";
+        return;
+    }
+
+    camA.set(cv::CAP_PROP_FRAME_WIDTH, 1280);  // TODO Use settings
+	camA.set(cv::CAP_PROP_FRAME_HEIGHT, 720);
+    camB.set(cv::CAP_PROP_FRAME_WIDTH, 1280);
+	camB.set(cv::CAP_PROP_FRAME_HEIGHT, 720);
+
+    Mat map1, map2, cameraMatrix;
+    Size imgSize(1280,720);
+    loadIntrinsicsMap((hasOption(opt, "profile")) ? getOption(opt,"profile") : "./panasonic.yml", imgSize, map1, map2, cameraMatrix, 1.0f);
+
+    Size boardSize(9,6);
+
+#if CV_VERSION_MAJOR >= 4
+	int chessBoardFlags = CALIB_CB_NORMALIZE_IMAGE;
+#else
+	int chessBoardFlags = CALIB_CB_ADAPTIVE_THRESH | CALIB_CB_NORMALIZE_IMAGE;
+
+	if (!settings.useFisheye) {
+		// fast check erroneously fails with high distortions like fisheye
+		chessBoardFlags |= CALIB_CB_FAST_CHECK;
+	}
+#endif
+
+    int step = 0;
+    bool anaglyph = true;
+
+    while (true) {
+        Mat frameA, fA;
+        Mat frameB, fB;
+
+        camA.grab();
+        camB.grab();
+        camA.retrieve(frameA);
+        camB.retrieve(frameB);
+
+        remap(frameA, fA, map1, map2, INTER_LINEAR);
+        remap(frameB, fB, map1, map2, INTER_LINEAR);
+
+        // Get the chessboard
+        vector<Point2f> pointBufA;
+        vector<Point2f> pointBufB;
+		bool foundA, foundB;
+        foundA = findChessboardCornersSB(fA, boardSize,
+				pointBufA, chessBoardFlags);
+        foundB = findChessboardCornersSB(fB, boardSize,
+				pointBufB, chessBoardFlags);
+
+    // Step 1: Position cameras correctly with respect to chessboard
+    //      - print distance estimate etc
+
+    // Step 2: Show individual camera tilt degrees with left / right indicators
+
+    // Show also up down tilt perspective error
+
+    // Step 3: Display current baseline in mm
+
+        if (foundA) {
+            // Draw the corners.
+			//drawChessboardCorners(fA, boardSize,
+			//		Mat(pointBufA), foundA);
+        }
+
+        if (foundB) {
+            // Draw the corners.
+			//drawChessboardCorners(fB, boardSize,
+			//		Mat(pointBufB), foundB);
+        }
+
+        Mat anag;
+        showAnaglyph(fA, fB, anag);
+
+        if (foundA) {
+			Rec4f dists = distances(cameraMatrix, pointBufA, boardSize, squareSize);
+			//Rec4f angs = angles(pointBufA, boardSize);
+
+			// TODO Check angles also...
+			bool lrValid = std::abs(dists.left-dists.right) <= kDistanceThreshold;
+			bool tbValid = std::abs(dists.top-dists.bottom) <= kDistanceThreshold;
+			bool distValid = lrValid & tbValid;
+			bool tiltUp = dists.top < dists.bottom && !tbValid;
+			bool tiltDown = dists.top > dists.bottom && !tbValid;
+			bool rotLeft = dists.left > dists.right && !lrValid;
+			bool rotRight = dists.left < dists.right && !lrValid;
+
+			float d = (dists.left + dists.right + dists.top + dists.bottom) / 4.0f;
+
+			// TODO Draw lines
+            Point2f bl = pointBufA[(boardSize.height-1)*boardSize.width];
+            Point2f tl = pointBufA[0];
+            Point2f tr = pointBufA[boardSize.width-1];
+            Point2f br = pointBufA[pointBufA.size()-1];
+
+
+            line(anag, tl, tr, (!lrValid && tiltUp) ? Scalar(0,0,255) : Scalar(0,255,0));
+            line(anag, bl, br, (!lrValid && tiltDown) ? Scalar(0,0,255) : Scalar(0,255,0));
+            line(anag, tl, bl, (!tbValid && rotLeft) ? Scalar(0,0,255) : Scalar(0,255,0));
+            line(anag, tr, br, (!tbValid && rotRight) ? Scalar(0,0,255) : Scalar(0,255,0));
+
+            //fA = fA(Rect(tl.x - 100, tl.y- 100, br.x-tl.x + 200, br.y-tl.y + 200));
+
+			// Show distance error between cameras
+
+			// Show estimated baseline
+
+            //if (step == 0) {
+            //    Point2f pd = parallaxDistortion(pointBufA, boardSize);
+            //    putText(fA, string("Distort: ") + std::to_string(pd.x) + string(",") + std::to_string(pd.y), Point(10,50), FONT_HERSHEY_PLAIN, 2.0, Scalar(0,0,255), 3);
+            //} else if (step == 1) {
+                //float d = distance(cameraMatrix, pointBufA, boardSize, squareSize);
+                //putText(anag, string("Distance: ") + std::to_string(-d) + string("m"), Point(10,50), FONT_HERSHEY_PLAIN, 2.0, Scalar(0,0,255), 3);
+           // } else if (step == 2) {
+                //float angle = calculateZRotation(pointBufA, boardSize) - 180.0f;
+                //putText(anag, string("Angle: ") + std::to_string(angle), Point(10,150), FONT_HERSHEY_PLAIN, 2.0, Scalar(0,0,255), 3);
+            //} else if (step == 3) {
+            //    Point2f vd = diffY(pointBufA, pointBufB, boardSize);
+            //    putText(fA, string("Vertical: ") + std::to_string(vd.x) + string(",") + std::to_string(vd.y), Point(10,200), FONT_HERSHEY_PLAIN, 2.0, Scalar(0,0,255), 3);
+           // }
+
+            if (foundB) {
+                //if (step == 0) {
+                    //Point2f pd = parallaxDistortion(pointBufB, boardSize);
+                    //putText(fB, string("Distort: ") + std::to_string(pd.x) + string(",") + std::to_string(pd.y), Point(10,50), FONT_HERSHEY_PLAIN, 2.0, Scalar(0,0,255), 3);
+                //} else if (step == 1) {
+                    //float d = distance(cameraMatrix, pointBufB, boardSize, squareSize);
+                    //putText(fB, string("Distance: ") + std::to_string(-d) + string("m"), Point(10,100), FONT_HERSHEY_PLAIN, 2.0, Scalar(0,0,255), 3);
+                //} else if (step == 2) {
+                    //float angle = calculateZRotation(pointBufB, boardSize) - 180.0f;
+                    //putText(fB, string("Angle: ") + std::to_string(angle), Point(10,150), FONT_HERSHEY_PLAIN, 2.0, Scalar(0,0,255), 3);
+                //}
+
+                Rec4f dists = distances(cameraMatrix, pointBufB, boardSize, squareSize);
+                //Rec4f angs = angles(pointBufA, boardSize);
+
+                // TODO Check angles also...
+                bool lrValid = std::abs(dists.left-dists.right) <= kDistanceThreshold;
+                bool tbValid = std::abs(dists.top-dists.bottom) <= kDistanceThreshold;
+                bool distValid = lrValid & tbValid;
+                bool tiltUp = dists.top < dists.bottom && !tbValid;
+                bool tiltDown = dists.top > dists.bottom && !tbValid;
+                bool rotLeft = dists.left > dists.right && !lrValid;
+                bool rotRight = dists.left < dists.right && !lrValid;
+
+                float d = (dists.left + dists.right + dists.top + dists.bottom) / 4.0f;
+
+                // TODO Draw lines
+                Point2f bbl = pointBufB[(boardSize.height-1)*boardSize.width];
+                Point2f btl = pointBufB[0];
+                Point2f btr = pointBufB[boardSize.width-1];
+                Point2f bbr = pointBufB[pointBufB.size()-1];
+
+
+                line(anag, btl, btr, (!lrValid && tiltUp) ? Scalar(0,0,255) : Scalar(0,255,0));
+                line(anag, bbl, bbr, (!lrValid && tiltDown) ? Scalar(0,0,255) : Scalar(0,255,0));
+                line(anag, btl, bbl, (!tbValid && rotLeft) ? Scalar(0,0,255) : Scalar(0,255,0));
+                line(anag, btr, bbr, (!tbValid && rotRight) ? Scalar(0,0,255) : Scalar(0,255,0));
+
+                float baseline1 = std::abs(tl.x - btl.x);
+                float baseline2 = std::abs(tr.x - btr.x);
+                float baseline3 = std::abs(bl.x - bbl.x);
+                float baseline4 = std::abs(br.x - bbr.x);
+                float boardWidth = (std::abs(tl.x-tr.x) + std::abs(btl.x - btr.x)) / 2.0f;
+                float baseline = ((baseline1 + baseline2 + baseline3 + baseline4) / 4.0f) / boardWidth * (boardSize.width*squareSize);
+
+                putText(anag, string("Baseline: ") + std::to_string(baseline) + string("mm"), Point(10,150), FONT_HERSHEY_PLAIN, 2.0, Scalar(0,255,0), 2);
+            }
+
+        }
+
+        /*if (anaglyph) {
+            showAnaglyph(fA,fB);
+        } else {
+            imshow("Left", fA);
+            imshow("Right", fB);
+        }*/
+        imshow("Anaglyph", anag);
+
+		char key = static_cast<char>(waitKey(20));
+		if (key  == 27)
+			break;
+        if (key == 32) anaglyph = !anaglyph;
+    }
+}
\ No newline at end of file
diff --git a/applications/calibration/src/align.hpp b/applications/calibration/src/align.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..8a2097c14c9b541299fd112c1f6f279387a1aede
--- /dev/null
+++ b/applications/calibration/src/align.hpp
@@ -0,0 +1,15 @@
+#ifndef _FTL_CALIBRATION_ALIGN_HPP_
+#define _FTL_CALIBRATION_ALIGN_HPP_
+
+#include <map>
+#include <string>
+
+namespace ftl {
+namespace calibration {
+
+void align(std::map<std::string, std::string> &opt);
+
+}
+}
+
+#endif  // _FTL_CALIBRATION_ALIGN_HPP_
diff --git a/applications/calibration/src/common.cpp b/applications/calibration/src/common.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a29bb8f76a05793526854b9f4958f9a04c4b1add
--- /dev/null
+++ b/applications/calibration/src/common.cpp
@@ -0,0 +1,111 @@
+#include <loguru.hpp>
+#include <ftl/config.h>
+
+#include <opencv2/calib3d.hpp>
+#include <opencv2/imgproc.hpp>
+#include <opencv2/videoio.hpp>
+#include <opencv2/highgui.hpp>
+
+#include "common.hpp"
+
+using std::vector;
+using std::map;
+using std::string;
+
+using cv::Mat;
+using cv::Vec2f, cv::Vec3f;
+using cv::Size;
+
+using cv::stereoCalibrate;
+
+namespace ftl {
+namespace calibration {
+
+// Options
+
+string getOption(map<string, string> &options, const string &opt) {
+	auto str = options[opt];
+	return str.substr(1,str.size()-2);
+}
+
+bool hasOption(const map<string, string> &options, const string &opt) {
+	return options.find(opt) != options.end();
+}
+
+// Save/load files
+
+bool saveExtrinsics(const string &ofile, Mat &R, Mat &T, Mat &R1, Mat &R2, Mat &P1, Mat &P2, Mat &Q) {
+	cv::FileStorage fs;
+	fs.open(ofile, cv::FileStorage::WRITE);
+	if (fs.isOpened()) {
+		fs << "R" << R << "T" << T << "R1" << R1 << "R2" << R2 << "P1"
+			<< P1 << "P2" << P2 << "Q" << Q;
+		fs.release();
+		return true;
+	} else {
+		LOG(ERROR) << "Error: can not save the extrinsic parameters";
+	}
+	return false;
+}
+
+bool saveIntrinsics(const string &ofile, const Mat &M, const Mat& D) {
+	cv::FileStorage fs(ofile, cv::FileStorage::WRITE);
+	if (fs.isOpened()) {
+		fs << "M" << M << "D" << D;
+		fs.release();
+		return true;
+	}
+	else {
+		LOG(ERROR) << "Error: can not save the intrinsic parameters to '" << ofile << "'";
+	}
+	return false;
+}
+
+bool loadIntrinsics(const string &ifile, Mat &M1, Mat &D1) {
+	using namespace cv;
+
+	FileStorage fs;
+
+	// reading intrinsic parameters
+	fs.open((ifile).c_str(), FileStorage::READ);
+	if (!fs.isOpened()) {
+		LOG(WARNING) << "Could not open intrinsics file : " << ifile;
+		return false;
+	}
+	
+	LOG(INFO) << "Intrinsics from: " << ifile;
+
+	fs["M"] >> M1;
+	fs["D"] >> D1;
+
+	return true;
+}
+
+// Calibration classes for different patterns
+
+CalibrationChessboard::CalibrationChessboard(const map<string, string> &opt) {
+	pattern_size_ = Size(9, 6);
+	image_size_ = Size(1280, 720);
+	pattern_square_size_ = 36.0;//0.036;
+	// CALIB_CB_NORMALIZE_IMAfE | CALIB_CB_EXHAUSTIVE | CALIB_CB_ACCURACY 
+	chessboard_flags_ = cv::CALIB_CB_NORMALIZE_IMAGE | cv::CALIB_CB_ACCURACY;
+}
+
+void CalibrationChessboard::objectPoints(vector<Vec3f> &out) {
+	out.reserve(pattern_size_.width * pattern_size_.height);
+	for (int row = 0; row < pattern_size_.height; ++row) {
+	for (int col = 0; col < pattern_size_.width; ++col) {
+		out.push_back(Vec3f(col * pattern_square_size_, row * pattern_square_size_, 0));
+	}}
+}
+
+bool CalibrationChessboard::findPoints(Mat &img, vector<Vec2f> &points) {
+	return cv::findChessboardCornersSB(img, pattern_size_, points, chessboard_flags_);
+}
+
+void CalibrationChessboard::drawPoints(Mat &img, const vector<Vec2f> &points) {
+	cv::drawChessboardCorners(img, pattern_size_, points, true);
+}
+
+}
+}
diff --git a/applications/calibration/src/common.hpp b/applications/calibration/src/common.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..4bc3d1e4b8c5e7f734821cce9b5af7b4f05a2605
--- /dev/null
+++ b/applications/calibration/src/common.hpp
@@ -0,0 +1,82 @@
+#ifndef _FTL_CALIBRATION_COMMON_HPP_
+#define _FTL_CALIBRATION_COMMON_HPP_
+
+#include <map>
+#include <string>
+
+#include <opencv2/core.hpp>
+
+namespace ftl {
+namespace calibration {
+
+std::string getOption(std::map<std::string, std::string> &options, const std::string &opt);
+bool hasOption(const std::map<std::string, std::string> &options, const std::string &opt);
+
+bool loadIntrinsics(const std::string &ifile, cv::Mat &M1, cv::Mat &D1);
+bool saveIntrinsics(const std::string &ofile, const cv::Mat &M1, const cv::Mat &D1);
+
+// TODO loadExtrinsics()
+bool saveExtrinsics(const std::string &ofile, cv::Mat &R, cv::Mat &T, cv::Mat &R1, cv::Mat &R2, cv::Mat &P1, cv::Mat &P2, cv::Mat &Q);
+
+/**
+ * @brief	Wrapper for OpenCV's calibration methods. Paramters depend on
+ * 			implementation (different types of patterns).
+ *
+ * Calibration objects may store state; eg. from previous views of calibration
+ * images.
+ */
+class Calibration {
+public:
+	/**
+	 * @brief	Calculate reference points for given pattern
+	 * @param	Output parameter
+	 */
+	void objectPoints(std::vector<cv::Vec3f> &out);
+
+	/**
+	 * @brief	Try to find calibration pattern in input image
+	 * @param	Input image
+	 * @param	Output parameter for found point image coordinates
+	 * @returns	true if pattern found, otherwise false
+	 */
+	bool findPoints(cv::Mat &in, std::vector<cv::Vec2f> &out);
+
+	/**
+	 * @brief	Draw points to image
+	 * @param	Image to draw to
+	 * @param	Pattern points (in image coordinates)
+	 */
+	void drawPoints(cv::Mat &img, const std::vector<cv::Vec2f> &points);
+};
+
+/**
+ * @brief	Chessboard calibration pattern. Uses OpenCV's
+ * 			findChessboardCornersSB function.
+ * @todo	Parameters hardcoded in constructor
+ *
+ * All parameters:
+ * 	- pattern size (inner corners)
+ * 	- square size, millimeters (TODO: meters)
+ * 	- image size, pixels
+ * 	- flags, see ChessboardCornersSB documentation
+ */
+class CalibrationChessboard : Calibration {
+public:
+	CalibrationChessboard(const std::map<std::string, std::string> &opt);
+	void objectPoints(std::vector<cv::Vec3f> &out);
+	bool findPoints(cv::Mat &in, std::vector<cv::Vec2f> &out);
+	void drawPoints(cv::Mat &img, const std::vector<cv::Vec2f> &points);
+
+private:
+	int chessboard_flags_ = 0;
+	float pattern_square_size_;
+	cv::Size pattern_size_;
+	cv::Size image_size_;
+};
+
+// TODO other patterns, circles ...
+
+}
+}
+
+#endif // _FTL_CALIBRATION_COMMON_HPP_
diff --git a/applications/calibration/src/lens.cpp b/applications/calibration/src/lens.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..afc54fd96def762cb61ff4f3ea9da5e0c087e5d7
--- /dev/null
+++ b/applications/calibration/src/lens.cpp
@@ -0,0 +1,121 @@
+#include "common.hpp"
+#include "lens.hpp"
+
+#include <loguru.hpp>
+
+#include <opencv2/core.hpp>
+#include <opencv2/core/utility.hpp>
+#include <opencv2/imgproc.hpp>
+#include <opencv2/calib3d.hpp>
+#include <opencv2/imgcodecs.hpp>
+#include <opencv2/videoio.hpp>
+#include <opencv2/highgui.hpp>
+
+#include <vector>
+
+using std::map;
+using std::string;
+using std::vector;
+
+using cv::Mat;
+using cv::Vec2f;
+using cv::Vec3f;
+using cv::Size;
+
+using namespace ftl::calibration;
+
+void ftl::calibration::intrinsic(map<string, string> &opt) {
+	LOG(INFO) << "Begin intrinsic calibration";
+
+	// TODO PARAMETERS TO CONFIG FILE
+	Size image_size = Size(1280, 720);
+	int iter = 60;
+	string filename_intrinsics = (hasOption(opt, "profile")) ? getOption(opt, "profile") : "./panasonic.yml";
+	CalibrationChessboard calib(opt); // TODO paramters hardcoded in constructor
+	
+
+	int calibrate_flags = cv::CALIB_ZERO_TANGENT_DIST | cv::CALIB_FIX_ASPECT_RATIO;
+	// PARAMETERS
+
+	cv::VideoCapture camera = cv::VideoCapture(0);
+
+	if (!camera.isOpened()) {
+		LOG(ERROR) << "Could not open camera device";
+		return;
+	}
+	camera.set(cv::CAP_PROP_FRAME_WIDTH, image_size.width); 
+	camera.set(cv::CAP_PROP_FRAME_HEIGHT, image_size.height);
+
+	vector<vector<Vec2f>> image_points;
+	vector<vector<Vec3f>> object_points;
+	
+	while (iter > 0) {
+		Mat img;
+		vector<Vec2f> points;
+		
+		camera.grab();
+		camera.retrieve(img);
+		
+		bool found = calib.findPoints(img, points);
+		if (found) { calib.drawPoints(img, points); }
+
+		cv::imshow("Camera", img);
+		cv::waitKey(750);
+
+		if (!found) { continue; }
+
+		vector<Vec3f> points_ref;
+		calib.objectPoints(points_ref);
+
+		Mat camera_matrix, dist_coeffs;
+		vector<Mat> rvecs, tvecs;
+		
+		/* slow
+		double rms = cv::calibrateCamera(
+							vector<vector<Vec3f>> { points_ref }, 
+							vector<vector<Vec2f>> { points },
+							image_size, camera_matrix, dist_coeffs,
+							rvecs, tvecs, calibrate_flags
+		);
+		
+		LOG(INFO) << "rms for pattern: " << rms;
+		if (rms > max_error) {
+			LOG(WARNING) << "RMS reprojection error too high, maximum allowed error: " << max_error;
+			continue;
+		}
+		*/
+	
+		image_points.push_back(points);
+		object_points.push_back(points_ref);
+
+		iter--;
+	}
+	
+	Mat camera_matrix, dist_coeffs;
+	vector<Mat> rvecs, tvecs;
+	
+	double rms = cv::calibrateCamera(
+						object_points, image_points,
+						image_size, camera_matrix, dist_coeffs,
+						rvecs, tvecs, calibrate_flags
+	);
+
+	LOG(INFO) << "final reprojection RMS error: " << rms;
+		
+	saveIntrinsics(filename_intrinsics, camera_matrix, dist_coeffs);
+	LOG(INFO) << "intrinsic paramaters saved to: " << filename_intrinsics;
+	
+	Mat map1, map2;
+	cv::initUndistortRectifyMap(camera_matrix, dist_coeffs, Mat::eye(3,3, CV_64F), camera_matrix,
+								image_size, CV_16SC2, map1, map2);
+
+	while (cv::waitKey(25) != 27) {
+		Mat img, img_out;
+		
+		camera.grab();
+		camera.retrieve(img);
+		cv::remap(img, img_out, map1, map2, cv::INTER_CUBIC);
+
+		cv::imshow("Camera", img_out);
+	}
+}
diff --git a/applications/calibration/src/lens.hpp b/applications/calibration/src/lens.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..ec455b6bd7b3de6085074e2ea6b6ca7cca3fee9b
--- /dev/null
+++ b/applications/calibration/src/lens.hpp
@@ -0,0 +1,15 @@
+#ifndef _FTL_CALIBRATION_LENS_HPP_
+#define _FTL_CALIBRATION_LENS_HPP_
+
+#include <map>
+#include <string>
+
+namespace ftl {
+namespace calibration {
+
+void intrinsic(std::map<std::string, std::string> &opt);
+
+}
+}
+
+#endif  // _FTL_CALIBRATION_LENS_HPP_
diff --git a/applications/calibration/src/main.cpp b/applications/calibration/src/main.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..698c7c9027513d8cea532f0b324f329adf5a6a08
--- /dev/null
+++ b/applications/calibration/src/main.cpp
@@ -0,0 +1,30 @@
+#include <loguru.hpp>
+#include <ftl/configuration.hpp>
+
+#include "lens.hpp"
+#include "stereo.hpp"
+#include "align.hpp"
+
+int main(int argc, char **argv) {
+	loguru::g_preamble_date = false;
+	loguru::g_preamble_uptime = false;
+	loguru::g_preamble_thread = false;
+	loguru::init(argc, argv, "--verbosity");
+	argc--;
+	argv++;
+
+	// Process Arguments
+	auto options = ftl::config::read_options(&argv, &argc);
+
+	if (options.find("intrinsic") != options.end()) {
+		ftl::calibration::intrinsic(options);
+	} else if (options.find("stereo") != options.end()) {
+		ftl::calibration::stereo(options);
+	} else if (options.find("align") != options.end()) {
+		ftl::calibration::align(options);
+	} else {
+		LOG(ERROR) << "Must have one of: --intrinsic --stereo or --align";
+	}
+
+	return 0;
+}
diff --git a/applications/calibration/src/stereo.cpp b/applications/calibration/src/stereo.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..65b93e18d306ec00d10298755a4ef6accbab01ff
--- /dev/null
+++ b/applications/calibration/src/stereo.cpp
@@ -0,0 +1,178 @@
+#include <loguru.hpp>
+#include <ftl/config.h>
+
+#include <opencv2/imgproc.hpp>
+#include <opencv2/videoio.hpp>
+#include <opencv2/highgui.hpp>
+
+#include "common.hpp"
+#include "stereo.hpp"
+
+using std::vector;
+using std::map;
+using std::string;
+
+using cv::Mat;
+using cv::Vec2f, cv::Vec3f;
+using cv::Size;
+
+using cv::stereoCalibrate;
+
+using namespace ftl::calibration;
+
+void ftl::calibration::stereo(map<string, string> &opt) {
+	LOG(INFO) << "Begin stereo calibration";
+
+	// TODO PARAMETERS TO CONFIG FILE
+	Size image_size = Size(1280, 720);
+	int iter = 30;
+	double max_error = 1.0;
+	float alpha = 0;
+	string filename_intrinsics = (hasOption(opt, "profile")) ? getOption(opt, "profile") : "./panasonic.yml";
+	CalibrationChessboard calib(opt); // TODO paramters hardcoded in constructor
+	// PARAMETERS
+
+	int stereocalibrate_flags =
+		cv::CALIB_FIX_INTRINSIC | cv::CALIB_FIX_PRINCIPAL_POINT | cv::CALIB_FIX_ASPECT_RATIO |
+		cv::CALIB_ZERO_TANGENT_DIST | cv::CALIB_SAME_FOCAL_LENGTH | cv::CALIB_RATIONAL_MODEL |
+		cv::CALIB_FIX_K3 | cv::CALIB_FIX_K4 | cv::CALIB_FIX_K5;
+
+	vector<cv::VideoCapture> cameras { cv::VideoCapture(0), cv::VideoCapture(1) };
+
+	for (auto &camera : cameras ) {
+		if (!camera.isOpened()) {
+			LOG(ERROR) << "Could not open camera device";
+			return;
+		}
+		camera.set(cv::CAP_PROP_FRAME_WIDTH, image_size.width); 
+		camera.set(cv::CAP_PROP_FRAME_HEIGHT, image_size.height);
+	}
+
+	vector<vector<vector<Vec2f>>> image_points(2);
+	vector<vector<Vec3f>> object_points;
+	vector<Mat> dist_coeffs(2);
+	vector<Mat> camera_matrices(2);
+
+	// assume identical cameras; load intrinsic parameters
+	loadIntrinsics(filename_intrinsics, camera_matrices[0], dist_coeffs[0]);
+	loadIntrinsics(filename_intrinsics, camera_matrices[1], dist_coeffs[1]);
+	
+	Mat R, T, E, F, per_view_errors;
+
+	while (iter > 0) {
+		int res = 0;
+
+		vector<Mat> new_img(2);
+		vector<vector<Vec2f>> new_points(2);
+
+		for (size_t i = 0; i < 2; i++) {
+			auto &camera = cameras[i];
+			auto &img = new_img[i];
+
+			camera.grab();
+			camera.retrieve(img);
+		}
+
+		for (size_t i = 0; i < 2; i++) {
+			auto &img = new_img[i];
+			auto &points = new_points[i];
+			
+			if (calib.findPoints(img, points)) {
+				calib.drawPoints(img, points);
+				res++;
+			}
+
+			cv::imshow("Camera: " + std::to_string(i), img);
+		}
+		cv::waitKey(750);
+
+		if (res != 2) { LOG(WARNING) << "Input not detected on all inputs"; }
+		else {
+			vector<Vec3f> points_ref;
+			calib.objectPoints(points_ref);
+			
+			// calculate reprojection error with single pair of images
+			// reject it if RMS reprojection error too high
+			int flags = stereocalibrate_flags;
+
+			double rms = stereoCalibrate(
+						vector<vector<Vec3f>> { points_ref }, 
+						vector<vector<Vec2f>> { new_points[0] },
+						vector<vector<Vec2f>> { new_points[1] },
+						camera_matrices[0], dist_coeffs[0],
+						camera_matrices[1], dist_coeffs[1],
+						image_size, R, T, E, F, per_view_errors,
+						flags);
+			
+			LOG(INFO) << "rms for pattern: " << rms;
+			if (rms > max_error) {
+				LOG(WARNING) << "RMS reprojection error too high, maximum allowed error: " << max_error;
+				continue;
+			}
+
+			object_points.push_back(points_ref);
+			for (size_t i = 0; i < 2; i++) {
+				image_points[i].push_back(new_points[i]);
+			}
+
+			iter--;
+		}
+	}
+
+	// calculate stereoCalibration using all input images (which have low enough
+	// RMS error in previous step)
+
+	double rms = stereoCalibrate(object_points,
+		image_points[0], image_points[1],
+		camera_matrices[0], dist_coeffs[0],
+		camera_matrices[1], dist_coeffs[1],
+		image_size, R, T, E, F, per_view_errors,
+		stereocalibrate_flags,
+		cv::TermCriteria(cv::TermCriteria::COUNT+cv::TermCriteria::EPS, 120, 1e-6)
+	);
+
+	LOG(INFO) << "Final extrinsic calibration RMS (reprojection error): " << rms;
+	for (int i = 0; i < per_view_errors.rows * per_view_errors.cols; i++) {
+		LOG(4) << "error for sample " << i << ": " << ((double*) per_view_errors.data)[i];
+	}
+
+	Mat R1, R2, P1, P2, Q;
+	cv::Rect validRoi[2];
+
+	// calculate extrinsic parameters
+	stereoRectify(
+		camera_matrices[0], dist_coeffs[0],
+		camera_matrices[1], dist_coeffs[1],
+		image_size, R, T, R1, R2, P1, P2, Q,
+		cv::CALIB_ZERO_DISPARITY, alpha, image_size,
+		&validRoi[0], &validRoi[1]
+	);
+
+	saveExtrinsics(FTL_LOCAL_CONFIG_ROOT "/extrinsics.yml", R, T, R1, R2, P1, P2, Q);
+	LOG(INFO) << "Stereo camera extrinsics saved to: " << FTL_LOCAL_CONFIG_ROOT "/extrinsics.yml";
+
+	// display results
+	vector<Mat> map1(2), map2(2);
+	cv::initUndistortRectifyMap(camera_matrices[0], dist_coeffs[0], R1, P1, image_size, CV_16SC2, map1[0], map2[0]);
+	cv::initUndistortRectifyMap(camera_matrices[1], dist_coeffs[1], R2, P2, image_size, CV_16SC2, map1[1], map2[1]);
+
+	vector<Mat> in(2);
+	vector<Mat> out(2);
+
+	while(cv::waitKey(50) == -1) {
+		for(size_t i = 0; i < 2; i++) {
+			auto &camera = cameras[i];
+			camera.grab();
+			camera.retrieve(in[i]);
+			cv::imshow("Camera: " + std::to_string(i), in[i]);
+			cv::remap(in[i], out[i], map1[i], map2[i], cv::INTER_CUBIC);
+
+			// draw lines
+			for (int r = 0; r < image_size.height; r = r+50) {
+				cv::line(out[i], cv::Point(0, r), cv::Point(image_size.width-1, r), cv::Scalar(0,0,255), 1);
+			}
+
+			cv::imshow("Camera " + std::to_string(i) + " (rectified)", out[i]);
+		}
+	}
+}
diff --git a/applications/calibration/src/stereo.hpp b/applications/calibration/src/stereo.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..0175ea366251fc9003d4571c97b06c9e38ad5a83
--- /dev/null
+++ b/applications/calibration/src/stereo.hpp
@@ -0,0 +1,18 @@
+#ifndef _FTL_CALIBRATION_STEREO_HPP_
+#define _FTL_CALIBRATION_STEREO_HPP_
+
+#include <map>
+#include <string>
+
+#include <opencv2/core.hpp>
+#include <opencv2/calib3d.hpp>
+
+namespace ftl {
+namespace calibration {
+
+void stereo(std::map<std::string, std::string> &opt);
+
+}
+}
+
+#endif  // _FTL_CALIBRATION_STEREO_HPP_
diff --git a/components/common/cpp/include/ftl/configuration.hpp b/components/common/cpp/include/ftl/configuration.hpp
index 9b832753eac89b737562bcee24b3b58eba8b8bd4..9010858c4a0c268c4b7603880eef79ad2d2a974d 100644
--- a/components/common/cpp/include/ftl/configuration.hpp
+++ b/components/common/cpp/include/ftl/configuration.hpp
@@ -29,6 +29,8 @@ typedef nlohmann::json json_t;
 
 std::optional<std::string> locateFile(const std::string &name);
 
+std::map<std::string, std::string> read_options(char ***argv, int *argc);
+
 Configurable *configure(int argc, char **argv, const std::string &root);
 
 Configurable *configure(json_t &);
diff --git a/components/common/cpp/src/configuration.cpp b/components/common/cpp/src/configuration.cpp
index dc6e59b977412729dd658de6fb49238b5c39cdfa..4990a35f3a3de0ad90c130fe9fe0dc56570fa73b 100644
--- a/components/common/cpp/src/configuration.cpp
+++ b/components/common/cpp/src/configuration.cpp
@@ -353,7 +353,7 @@ static bool findConfiguration(const string &file, const vector<string> &paths) {
 /**
  * Generate a map from command line option to value
  */
-static map<string, string> read_options(char ***argv, int *argc) {
+map<string, string> ftl::config::read_options(char ***argv, int *argc) {
 	map<string, string> opts;
 
 	while (*argc > 0) {
@@ -461,7 +461,7 @@ Configurable *ftl::config::configure(int argc, char **argv, const std::string &r
 #endif  // WIN32
 
 	// Process Arguments
-	auto options = read_options(&argv, &argc);
+	auto options = ftl::config::read_options(&argv, &argc);
 	
 	vector<string> paths;
 	while (argc-- > 0) {
diff --git a/components/rgbd-sources/src/calibrate.cpp b/components/rgbd-sources/src/calibrate.cpp
index 7fe953c03963951ca2ebe5cb3fa1228274fc2434..4addc38e33b3dba8afaf86952818f66cb0ea1895 100644
--- a/components/rgbd-sources/src/calibrate.cpp
+++ b/components/rgbd-sources/src/calibrate.cpp
@@ -219,7 +219,7 @@ bool Calibrate::_loadCalibration() {
     FileStorage fs;
 
     // reading intrinsic parameters
-    auto ifile = ftl::locateFile("intrinsics.yml");
+    auto ifile = ftl::locateFile(value("intrinsics", std::string("intrinsics.yml")));
     if (ifile) {
 		fs.open((*ifile).c_str(), FileStorage::READ);
 		if (!fs.isOpened()) {
@@ -234,13 +234,16 @@ bool Calibrate::_loadCalibration() {
     }
 
     Mat M1, D1, M2, D2;
-    fs["M1"] >> M1;
-    fs["D1"] >> D1;
-    fs["M2"] >> M2;
-    fs["D2"] >> D2;
+    fs["M"] >> M1;
+    fs["D"] >> D1;
+    //fs["M2"] >> M2;
+    //fs["D2"] >> D2;
 
     M1 *= scale;
-    M2 *= scale;
+    //M2 *= scale;
+
+	M2 = M1;
+	D2 = D1;
 
 	auto efile = ftl::locateFile("extrinsics.yml");
 	if (efile) {