diff --git a/CMakeLists.txt b/CMakeLists.txt
index bc61a7a6736a252e5b39583dca9246b526719dcb..338ed2cb51b53c80d257ef1228e504c782c4a17f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -231,6 +231,30 @@ else()
 	message(WARNING "Portaudio not found - sound disabled")
 endif()
 
+# Assimp library
+#find_library( ASSIMP_LIBRARY NAMES assimp PATHS ${PORTAUDIO_DIR} PATH_SUFFIXES lib)
+#if (ASSIMP_LIBRARY)
+#	set(HAVE_ASSIMP TRUE)
+#	add_library(assimp UNKNOWN IMPORTED)
+	#set_property(TARGET nanogui PROPERTY INTERFACE_INCLUDE_DIRECTORIES ${NANOGUI_EXTRA_INCS})
+#	set_property(TARGET assimp PROPERTY IMPORTED_LOCATION ${ASSIMP_LIBRARY})
+#	message(STATUS "Found Assimp: ${ASSIMP_LIBRARY}")
+
+#	if(WIN32)
+		# Find include
+#		find_path(ASSIMP_INCLUDE_DIRS
+#			NAMES assimp/scene.h
+#			PATHS "C:/Program Files/Assimp" "C:/Program Files (x86)/Assimp" ${ASSIMP_DIR}
+#			PATH_SUFFIXES include
+#		)
+#		set_property(TARGET assimp PROPERTY INTERFACE_INCLUDE_DIRECTORIES ${ASSIMP_INCLUDE_DIRS})
+#	endif()
+#else()
+#	set(ASSIMP_LIBRARY "")
+#	add_library(assimp INTERFACE)
+#	message(WARNING "Assimp not found - no model rendering")
+#endif()
+
 find_program( NODE_NPM NAMES npm )
 if (NODE_NPM)
 	message(STATUS "Found NPM: ${NODE_NPM}")
diff --git a/applications/gui/src/camera.cpp b/applications/gui/src/camera.cpp
index 8870944377a742e67fdd0912881187375f12651e..c981c662c8ef2dae9729f239716a20e9f446f459 100644
--- a/applications/gui/src/camera.cpp
+++ b/applications/gui/src/camera.cpp
@@ -12,6 +12,7 @@
 #include <ftl/cuda/normals.hpp>
 #include <ftl/render/colouriser.hpp>
 #include <ftl/cuda/transform.hpp>
+#include <ftl/operators/gt_analysis.hpp>
 
 #include <ftl/render/overlay.hpp>
 #include "statsimage.hpp"
@@ -359,6 +360,9 @@ void ftl::gui::Camera::_draw(std::vector<ftl::rgbd::FrameSet*> &fss) {
 				//}
 			}
 
+			renderer_->render();
+			if (isStereo()) renderer2_->render();
+
 			if (channel_ != Channel::Left && channel_ != Channel::Right && channel_ != Channel::None) {
 				renderer_->blend(channel_);
 				if (isStereo()) {
@@ -385,6 +389,7 @@ void ftl::gui::Camera::_draw(std::vector<ftl::rgbd::FrameSet*> &fss) {
 	if (!post_pipe_) {
 		post_pipe_ = ftl::config::create<ftl::operators::Graph>(screen_->root(), "post_filters");
 		post_pipe_->append<ftl::operators::FXAA>("fxaa");
+		post_pipe_->append<ftl::operators::GTAnalysis>("gtanal");
 	}
 
 	post_pipe_->apply(frame_, frame_, 0);
@@ -465,6 +470,11 @@ void ftl::gui::Camera::update(std::vector<ftl::rgbd::FrameSet*> &fss) {
 			if ((size_t)fid_ >= fs->frames.size()) return;
 			frame = &fs->frames[fid_];
 
+			if (frame->hasChannel(Channel::Messages)) {
+				msgs_.clear();
+				frame->get(Channel::Messages, msgs_);
+			}
+
 			auto n = frame->get<std::string>("name");
 			if (n) {
 				name_ = *n;
diff --git a/applications/gui/src/camera.hpp b/applications/gui/src/camera.hpp
index d8c2dda708e9858b4929e4613fb779dc2510a271..764a07b9509c08166c7c2e8839a3bc8f74f6dfea 100644
--- a/applications/gui/src/camera.hpp
+++ b/applications/gui/src/camera.hpp
@@ -114,6 +114,7 @@ class Camera {
 	//cv::Point3f getNormal(float x, float y) { return getNormal((int) round(x), (int) round(y)); }
 	void setTransform(const Eigen::Matrix4d &T);
 	Eigen::Matrix4d getTransform() const;
+	const std::vector<std::string> &getMessages() const { return msgs_; }
 
 #ifdef HAVE_OPENVR
 	bool isVR() { return vr_mode_; }
@@ -174,6 +175,8 @@ class Camera {
 
 	std::string name_;
 
+	std::vector<std::string> msgs_;
+
 	int transform_ix_;
 	std::array<Eigen::Matrix4d,ftl::stream::kMaxStreams> transforms_;  // Frameset transforms for virtual cam
 	Eigen::Matrix4d T_ = Eigen::Matrix4d::Identity();
diff --git a/applications/gui/src/media_panel.cpp b/applications/gui/src/media_panel.cpp
index 63d9d911a7de8ae24a62ff4d5b43159543d50f8f..182324deb9d628f716bac0d515a41d2c9c30c0ed 100644
--- a/applications/gui/src/media_panel.cpp
+++ b/applications/gui/src/media_panel.cpp
@@ -174,7 +174,8 @@ MediaPanel::MediaPanel(ftl::gui::Screen *screen, ftl::gui::SourceWindow *sourceW
 	popup = popbutton->popup();
 	popup->setLayout(new GroupLayout());
 	popup->setTheme(screen->toolbuttheme);
-	popup->setAnchorHeight(150);
+	//popup->setAnchorHeight(150);
+	more_button_ = popup;
 
 	for (int i=3; i<32; ++i) {
 		ftl::codecs::Channel c = static_cast<ftl::codecs::Channel>(i);
@@ -251,6 +252,11 @@ void MediaPanel::cameraChanged() {
 	}
 }
 
+void MediaPanel::performLayout(NVGcontext *ctx) {
+	nanogui::Window::performLayout(ctx);
+	more_button_->setAnchorHeight(more_button_->height()-20);
+}
+
 void MediaPanel::recordWindowClosed() {
 	recordbutton_->setEnabled(true);
 }
\ No newline at end of file
diff --git a/applications/gui/src/media_panel.hpp b/applications/gui/src/media_panel.hpp
index 4cf69b5ba204d268ad9143b582d5a71a6d407c67..94700cad073c00fec51bfd5135476938d510de06 100644
--- a/applications/gui/src/media_panel.hpp
+++ b/applications/gui/src/media_panel.hpp
@@ -34,6 +34,8 @@ class MediaPanel : public nanogui::Window {
 
 	void recordWindowClosed();
 
+	void performLayout(NVGcontext *ctx) override;
+
 	private:
 	ftl::gui::Screen *screen_;
 	ftl::gui::SourceWindow *sourceWindow_;
@@ -45,6 +47,7 @@ class MediaPanel : public nanogui::Window {
 	nanogui::PopupButton *button_channels_;
 	//nanogui::Button *right_button_;
 	//nanogui::Button *depth_button_;
+	nanogui::Popup *more_button_;
 	nanogui::PopupButton *recordbutton_;
 	std::array<nanogui::Button*,32> channel_buttons_={};
 
diff --git a/applications/gui/src/screen.cpp b/applications/gui/src/screen.cpp
index 5ad79c9729dc8ddd263207ec303c277da2296277..533da2c4de6aa57531bd4568d38fc2ac836f47ea 100644
--- a/applications/gui/src/screen.cpp
+++ b/applications/gui/src/screen.cpp
@@ -604,6 +604,8 @@ void ftl::gui::Screen::draw(NVGcontext *ctx) {
 
 	nvgTextAlign(ctx, NVG_ALIGN_RIGHT);
 
+	int offset_top = 20;
+
 	if (root()->value("show_information", true)) {
 		string msg;
 
@@ -623,6 +625,18 @@ void ftl::gui::Screen::draw(NVGcontext *ctx) {
 			nvgText(ctx, screenSize[0]-10, 80, msg.c_str(), NULL);
 			msg = string("Focal: ") + to_string_with_precision(intrin.fx, 2);
 			nvgText(ctx, screenSize[0]-10, 100, msg.c_str(), NULL);
+
+			offset_top = 120;
+		} else {
+			offset_top = 80;
+		}
+	}
+
+	if (camera_) {
+		auto &msgs = camera_->getMessages();
+		for (auto &m : msgs) {
+			nvgText(ctx, screenSize[0]-10, offset_top, m.c_str(), NULL);
+			offset_top += 20;
 		}
 	}
 
diff --git a/applications/gui/src/src_window.cpp b/applications/gui/src/src_window.cpp
index b9b9b957f3f8edb65d77f6374cacb6e6ae34f67b..fe3763252538ec1a2ac2597afdb97035c90c8eca 100644
--- a/applications/gui/src/src_window.cpp
+++ b/applications/gui/src/src_window.cpp
@@ -34,6 +34,7 @@
 #include <ftl/operators/mvmls.hpp>
 #include <ftl/operators/clipping.hpp>
 #include <ftl/operators/poser.hpp>
+#include <ftl/operators/gt_analysis.hpp>
 
 #include <nlohmann/json.hpp>
 
@@ -85,7 +86,7 @@ SourceWindow::SourceWindow(ftl::gui::Screen *screen)
 
 	auto vscroll = new VScrollPanel(this);
 	ipanel_ = new Widget(vscroll);
-	ipanel_->setLayout(new GridLayout(nanogui::Orientation::Horizontal, 2,
+	ipanel_->setLayout(new GridLayout(nanogui::Orientation::Horizontal, 3,
 		nanogui::Alignment::Middle, 0, 5));
 
 	screen->net()->onConnect([this](ftl::net::Peer *p) {
@@ -273,6 +274,7 @@ void SourceWindow::_checkFrameSets(size_t id) {
 		p->append<ftl::operators::CullDiscontinuity>("remove_discontinuity");
 		p->append<ftl::operators::MultiViewMLS>("mvmls")->value("enabled", false);
 		p->append<ftl::operators::Poser>("poser")->value("enabled", true);
+		p->append<ftl::operators::GTAnalysis>("gtanal");
 
 		pre_pipelines_.push_back(p);
 		framesets_.push_back(new ftl::rgbd::FrameSet);
diff --git a/applications/tools/CMakeLists.txt b/applications/tools/CMakeLists.txt
index a96555932de3a2389e62059687813ea8f04a216d..0506376d54652e307351142caf71628a4f8d0528 100644
--- a/applications/tools/CMakeLists.txt
+++ b/applications/tools/CMakeLists.txt
@@ -1 +1,7 @@
 add_subdirectory(codec_eval)
+
+#if (HAVE_ASSIMP)
+#    add_subdirectory(model_truth)
+#endif()
+
+add_subdirectory(middlebury_gen)
diff --git a/applications/tools/middlebury_gen/CMakeLists.txt b/applications/tools/middlebury_gen/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..2dffb172d22cfc554088f3971bb90ff2fd60e8e7
--- /dev/null
+++ b/applications/tools/middlebury_gen/CMakeLists.txt
@@ -0,0 +1,17 @@
+set(MIDSRC
+	src/main.cpp
+)
+
+add_executable(middlebury-gen ${MIDSRC})
+
+target_include_directories(middlebury-gen PUBLIC
+	#$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
+	#$<INSTALL_INTERFACE:include>
+	PRIVATE src)
+
+if (CUDA_FOUND)
+set_property(TARGET middlebury-gen PROPERTY CUDA_SEPARABLE_COMPILATION ON)
+endif()
+
+#target_include_directories(cv-node PUBLIC ${PROJECT_SOURCE_DIR}/include)
+target_link_libraries(middlebury-gen ftlcommon ftlrgbd Threads::Threads ${OpenCV_LIBS} ftlrender ftloperators ftlstreams)
diff --git a/applications/tools/middlebury_gen/src/main.cpp b/applications/tools/middlebury_gen/src/main.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..043db7d474d304d24f4cf0f5ee3005e8c9281ca4
--- /dev/null
+++ b/applications/tools/middlebury_gen/src/main.cpp
@@ -0,0 +1,335 @@
+#include <ftl/configuration.hpp>
+#include <ftl/streams/filestream.hpp>
+#include <ftl/operators/disparity.hpp>
+#include <ftl/codecs/opencv_encoder.hpp>
+#include <ftl/streams/injectors.hpp>
+
+#include <opencv2/imgcodecs.hpp>
+#include <opencv2/imgproc.hpp>
+#include <opencv2/highgui.hpp>
+#include <nlohmann/json.hpp>
+
+#define LOGURU_REPLACE_GLOG 1
+#include <loguru.hpp>
+
+using ftl::codecs::Channel;
+using ftl::codecs::definition_t;
+using ftl::codecs::codec_t;
+using std::string;
+
+static bool loadMiddleburyCalib(const std::string &filename, ftl::rgbd::Camera &p1, ftl::rgbd::Camera &p2, int &ndisp, double scaling) {
+	FILE* fp = fopen(filename.c_str(), "r");
+	
+	float cam0[3][3] = {};
+	float cam1[3][3];
+	float doffs = 0.0f;
+	float baseline = 0.0f;
+	int width = 0;
+	int height = 0;
+	//int ndisp;
+	int isint;
+	int vmin;
+	int vmax;
+	float dyavg;
+	float dymax;
+
+	if (fp != nullptr) {
+		char buff[512];
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "cam0 = [%f %f %f; %f %f %f; %f %f %f]\n", &cam0[0][0], &cam0[0][1], &cam0[0][2], &cam0[1][0], &cam0[1][1], &cam0[1][2], &cam0[2][0], &cam0[2][1], &cam0[2][2]);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "cam1 = [%f %f %f; %f %f %f; %f %f %f]\n", &cam1[0][0], &cam1[0][1], &cam1[0][2], &cam1[1][0], &cam1[1][1], &cam1[1][2], &cam1[2][0], &cam1[2][1], &cam1[2][2]);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "doffs = %f\n", &doffs);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "baseline = %f\n", &baseline);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "width = %d\n", &width);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "height = %d\n", &height);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "ndisp = %d\n", &ndisp);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "isint = %d\n", &isint);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "vmin = %d\n", &vmin);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "vmax = %d\n", &vmax);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "dyavg = %f\n", &dyavg);
+		if (fgets(buff, sizeof(buff), fp) != nullptr) sscanf(buff, "dymax = %f\n", &dymax);
+		fclose(fp);
+
+		p1.fx = cam0[0][0] * scaling;
+		p1.fy = p1.fx;
+		p1.cx = -cam0[0][2] * scaling;
+		p1.cy = -cam0[1][2] * scaling;
+		p1.width = width * scaling;
+		p1.height = height * scaling;
+		p1.baseline = baseline / 1000.0f;
+		p1.doffs = doffs * scaling;
+		p1.maxDepth = p1.baseline * p1.fx / (float(vmin) + p1.doffs);
+		p1.minDepth = p1.baseline * p1.fx / (float(vmax) + p1.doffs);
+		p1.doffs = -p1.doffs;
+
+		p2 = p1;
+		p2.fx = cam1[0][0] * scaling;
+		p2.fy = p2.fx;
+		p2.cx = -cam1[0][2] * scaling;
+		p2.cy = -cam1[1][2] * scaling;
+
+		return true;
+	}
+
+	return false;
+}
+
+static void skip_comment(FILE *fp) {
+    // skip comment lines in the headers of pnm files
+
+    char c;
+    while ((c=getc(fp)) == '#')
+        while (getc(fp) != '\n') ;
+    ungetc(c, fp);
+}
+
+static void skip_space(FILE *fp) {
+    // skip white space in the headers or pnm files
+
+    char c;
+    do {
+        c = getc(fp);
+    } while (c == '\n' || c == ' ' || c == '\t' || c == '\r');
+    ungetc(c, fp);
+}
+
+static void read_header(FILE *fp, const char *imtype, char c1, char c2, 
+                 int *width, int *height, int *nbands, int thirdArg)
+{
+    // read the header of a pnmfile and initialize width and height
+
+    char c;
+  
+	if (getc(fp) != c1 || getc(fp) != c2)
+		LOG(FATAL) << "ReadFilePGM: wrong magic code for " << imtype << " file";
+	skip_space(fp);
+	skip_comment(fp);
+	skip_space(fp);
+	if (fscanf(fp, "%d", width) <= 0) {
+		LOG(FATAL) << "PFM file error";
+	};
+	skip_space(fp);
+	if (fscanf(fp, "%d", height) <= 0) {
+		LOG(FATAL) << "PFM file error";
+	}
+	if (thirdArg) {
+		skip_space(fp);
+		if (fscanf(fp, "%d", nbands) <= 0) {
+			LOG(FATAL) << "PFM file error";
+		}
+	}
+    // skip SINGLE newline character after reading image height (or third arg)
+	c = getc(fp);
+    if (c == '\r')      // <cr> in some files before newline
+        c = getc(fp);
+    if (c != '\n') {
+        if (c == ' ' || c == '\t' || c == '\r')
+            LOG(FATAL) << "newline expected in file after image height";
+        else
+            LOG(FATAL) << "whitespace expected in file after image height";
+  }
+}
+
+// check whether machine is little endian
+static int littleendian() {
+    int intval = 1;
+    uchar *uval = (uchar *)&intval;
+    return uval[0] == 1;
+}
+
+// 1-band PFM image, see http://netpbm.sourceforge.net/doc/pfm.html
+// 3-band not yet supported
+static void readFilePFM(cv::Mat &img, const string &filename)
+{
+    // Open the file and read the header
+    FILE *fp = fopen(filename.c_str(), "rb");
+    if (fp == 0)
+        LOG(FATAL) << "ReadFilePFM: could not open \"" << filename << "\"";
+
+    int width, height, nBands;
+    read_header(fp, "PFM", 'P', 'f', &width, &height, &nBands, 0);
+
+    skip_space(fp);
+
+    float scalef;
+    if (fscanf(fp, "%f", &scalef) <= 0) {  // scale factor (if negative, little endian)
+		LOG(FATAL) << "Invalid PFM file";
+	}
+
+    // skip SINGLE newline character after reading third arg
+    char c = getc(fp);
+    if (c == '\r')      // <cr> in some files before newline
+        c = getc(fp);
+    if (c != '\n') {
+        if (c == ' ' || c == '\t' || c == '\r')
+            LOG(FATAL) << "newline expected in file after scale factor";
+        else
+            LOG(FATAL) << "whitespace expected in file after scale factor";
+    }
+    
+    // Allocate the image if necessary
+    img = cv::Mat(height, width, CV_32FC1);
+    // Set the image shape
+    //Size sh = img.size();
+
+    int littleEndianFile = (scalef < 0);
+    int littleEndianMachine = littleendian();
+    int needSwap = (littleEndianFile != littleEndianMachine);
+    //printf("endian file = %d, endian machine = %d, need swap = %d\n", 
+    //       littleEndianFile, littleEndianMachine, needSwap);
+
+    for (int y = height-1; y >= 0; y--) { // PFM stores rows top-to-bottom!!!!
+	int n = width;
+	float* ptr = &img.at<float>(y, 0, 0);
+	if ((int)fread(ptr, sizeof(float), n, fp) != n)
+	    LOG(FATAL) << "ReadFilePFM(" << filename << "): file is too short";
+	
+	if (needSwap) { // if endianness doesn't agree, swap bytes
+	    uchar* ptr = (uchar *)&img.at<uchar>(y, 0, 0);
+	    int x = 0;
+	    uchar tmp = 0;
+	    while (x < n) {
+		tmp = ptr[0]; ptr[0] = ptr[3]; ptr[3] = tmp;
+		tmp = ptr[1]; ptr[1] = ptr[2]; ptr[2] = tmp;
+		ptr += 4;
+		x++;
+	    }
+	}
+    }
+    if (fclose(fp))
+        LOG(FATAL) << "ReadFilePGM(" << filename << "): error closing file";
+}
+
+int main(int argc, char **argv) {
+	auto *root = ftl::configure(argc, argv, "tools_default");
+
+	ftl::stream::File *out = ftl::create<ftl::stream::File>(root, "output");
+	out->set("filename", root->value("out", std::string("./out.ftl")));
+	out->setMode(ftl::stream::File::Mode::Write);
+	out->begin(false);
+
+	int height = root->value("height", 1080);
+
+	// For each middlebury test folder
+	auto paths = (*root->get<nlohmann::json>("paths"));
+
+	ftl::rgbd::Frame frame;
+	ftl::rgbd::FrameState state;
+
+	ftl::operators::DisparityToDepth disp2depth(ftl::create<ftl::Configurable>(root, "disparity"));
+
+	ftl::codecs::OpenCVEncoder encoder(ftl::codecs::definition_t::Any, ftl::codecs::definition_t::Any);
+
+	int i=0;
+	for (auto &x : paths.items()) {
+		std::string dir = x.value().get<std::string>();
+
+		std::vector<std::string> dirents = ftl::directory_listing(dir);
+		for (auto &path : dirents) {
+			// Validate the folder as middlebury
+			ftl::rgbd::Camera intrin1;
+			ftl::rgbd::Camera intrin2;
+			int ndisp = 0;
+			if (!loadMiddleburyCalib(path+"/calib.txt", intrin1, intrin2, ndisp, 1.0f)) {
+				LOG(ERROR) << "Could not load middlebury calibration: " << path;
+				continue;
+			}
+
+			frame.reset();
+
+			// Load the colour images into a frame.
+			frame.create<cv::Mat>(Channel::Colour) = cv::imread(path+"/im0.png", cv::IMREAD_COLOR);
+			frame.create<cv::Mat>(Channel::Colour2) = cv::imread(path+"/im1.png", cv::IMREAD_COLOR);
+
+			// Colour convert
+			auto &c1 = frame.get<cv::Mat>(Channel::Colour);
+			auto &c2 = frame.get<cv::Mat>(Channel::Colour2);
+			cv::cvtColor(c1,c1, cv::COLOR_BGR2RGBA);
+			cv::cvtColor(c2,c2, cv::COLOR_BGR2RGBA);
+
+			// Load the ground truth
+			//frame.create<cv::Mat>(Channel::Disparity) = cv::imread(path+"/disp0.pfm", cv::IMREAD_UNCHANGED);
+			readFilePFM(frame.create<cv::Mat>(Channel::Disparity), path+"/disp0.pfm");
+			cv::Mat &disp = frame.get<cv::Mat>(Channel::Disparity);
+			float aspect = float(disp.cols) / float(disp.rows);
+			float scaling = float(height) / float(disp.rows);
+			cv::resize(disp, disp, cv::Size(int(aspect*float(height)),height), 0.0, 0.0, cv::INTER_NEAREST);
+			cv::resize(c1, c1, cv::Size(int(aspect*float(height)),height));
+			cv::resize(c2, c2, cv::Size(int(aspect*float(height)),height));
+
+			int original_width = c1.cols;
+			int desired_width = ftl::codecs::getWidth(ftl::codecs::findDefinition(height));
+			int border_size = (desired_width - c1.cols) / 2;
+			cv::copyMakeBorder(c1, c1, 0, 0, border_size, desired_width - border_size - c1.cols, cv::BORDER_CONSTANT, cv::Scalar(0));
+			cv::copyMakeBorder(c2, c2, 0, 0, border_size, desired_width - border_size - c2.cols, cv::BORDER_CONSTANT, cv::Scalar(0));
+			cv::copyMakeBorder(disp, disp, 0, 0, border_size, desired_width - border_size - disp.cols, cv::BORDER_CONSTANT, cv::Scalar(0));
+
+			// TODO: Adjust principle points (cx)
+
+			LOG(INFO) << "Disparity scaling: " << scaling;
+			LOG(INFO) << "Depth range: " << intrin1.minDepth << " to " << intrin1.maxDepth;
+			LOG(INFO) << "Resolution: " << c1.cols << "x" << c1.rows;
+			disp.convertTo(disp, CV_32F, scaling);
+
+			intrin1 = intrin1.scaled(original_width, c1.rows);
+			intrin2 = intrin2.scaled(original_width, c2.rows);
+			intrin1.cx -= border_size;
+			intrin2.cx -= border_size;
+			intrin1.width = c1.cols;
+			intrin2.width = c2.cols;
+
+			state.setLeft(intrin1);
+			state.setRight(intrin2);
+			frame.setOrigin(&state);
+			ftl::stream::injectCalibration(out, frame, 0, 0, i, false);
+			ftl::stream::injectCalibration(out, frame, 0, 0, i, true);
+
+			// Convert disparity to depth
+			frame.upload(Channel::Disparity + Channel::Colour + Channel::Colour2);
+
+			disp2depth.apply(frame, frame, 0);
+
+			// Encode the frame into the output stream
+			ftl::codecs::StreamPacket spkt;
+			ftl::codecs::Packet pkt;
+
+			spkt.timestamp = 0;
+			spkt.frame_number = i;
+			spkt.streamID = 0;
+			spkt.version = 4;
+			pkt.codec = codec_t::Any;
+			pkt.definition = definition_t::Any;
+			pkt.bitrate = 0;
+			pkt.flags = 0;
+			pkt.frame_count = 1;
+
+			spkt.channel = Channel::Colour;
+			if (!encoder.encode(frame.get<cv::cuda::GpuMat>(Channel::Colour), pkt)) {
+				LOG(ERROR) << "Encode failed for colour";
+			}
+			out->post(spkt, pkt);
+
+			pkt.codec = codec_t::Any;
+			pkt.definition = definition_t::Any;
+			spkt.channel = Channel::Colour2;
+			if (!encoder.encode(frame.get<cv::cuda::GpuMat>(Channel::Colour2), pkt)) {
+				LOG(ERROR) << "Encode failed for colour2";
+			}
+			out->post(spkt, pkt);
+
+			spkt.channel = Channel::GroundTruth;
+			pkt.flags = ftl::codecs::kFlagFloat;
+			pkt.codec = codec_t::Any;
+			pkt.definition = definition_t::Any;
+			if (!encoder.encode(frame.get<cv::cuda::GpuMat>(Channel::Depth), pkt)) {
+				LOG(ERROR) << "Encode failed for depth";
+			}
+			out->post(spkt, pkt);
+
+			++i;
+		}
+	}
+
+	out->end();
+
+	return 0;
+}
\ No newline at end of file
diff --git a/applications/tools/model_truth/CMakeLists.txt b/applications/tools/model_truth/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9b386f7ac4152ab2fa169f38f79d94a480d75213
--- /dev/null
+++ b/applications/tools/model_truth/CMakeLists.txt
@@ -0,0 +1,17 @@
+set(GTSRC
+	src/main.cpp
+)
+
+add_executable(model-truth ${GTSRC})
+
+target_include_directories(model-truth PUBLIC
+	#$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
+	#$<INSTALL_INTERFACE:include>
+	PRIVATE src)
+
+if (CUDA_FOUND)
+set_property(TARGET model-truth PROPERTY CUDA_SEPARABLE_COMPILATION ON)
+endif()
+
+#target_include_directories(cv-node PUBLIC ${PROJECT_SOURCE_DIR}/include)
+target_link_libraries(model-truth ftlcommon ftlrgbd Threads::Threads ${OpenCV_LIBS} ftlctrl ftlnet ftlrender ftloperators ftlstreams assimp)
diff --git a/applications/tools/model_truth/src/main.cpp b/applications/tools/model_truth/src/main.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1712a76833cf78a51a3424567a4cbdda6162866c
--- /dev/null
+++ b/applications/tools/model_truth/src/main.cpp
@@ -0,0 +1,98 @@
+#include <ftl/configuration.hpp>
+#include <ftl/streams/filestream.hpp>
+#include <ftl/streams/parsers.hpp>
+#include <ftl/render/assimp_render.hpp>
+
+#include <vector>
+
+#define LOGURU_REPLACE_GLOG 1
+#include <loguru.hpp>
+
+#include <GLFW/glfw3.h>
+
+using std::vector;
+using std::string;
+using ftl::codecs::Channel;
+
+struct CameraSpecs {
+    ftl::rgbd::Camera intrinsics1;
+    ftl::rgbd::Camera intrinsics2;
+    Eigen::Matrix4d pose;
+};
+
+int main(int argc, char **argv) {
+	auto *root = ftl::configure(argc, argv, "tools_default");
+
+    vector<CameraSpecs> cameras;
+	vector<ftl::rgbd::Frame> frames;
+
+    // Use existing FTL as pose and intrinsics source
+    ftl::stream::File *inftl = ftl::create<ftl::stream::File>(root, "input_ftl");
+    inftl->set("filename", root->value("in", std::string("")));
+
+    inftl->onPacket([&cameras](const ftl::codecs::StreamPacket &spkt, const ftl::codecs::Packet &pkt) {
+        if (spkt.channel == Channel::Pose) {
+            if (cameras.size() <= spkt.frameNumber()) cameras.resize(spkt.frameNumber()+1);
+            CameraSpecs &spec = cameras[spkt.frameNumber()];
+
+            spec.pose = ftl::stream::parsePose(pkt);
+
+            LOG(INFO) << "HAVE POSE: " << spec.pose;
+        } else if (spkt.channel == Channel::Calibration) {
+            if (cameras.size() <= spkt.frameNumber()) cameras.resize(spkt.frameNumber()+1);
+            CameraSpecs &spec = cameras[spkt.frameNumber()];
+
+            spec.intrinsics1 = ftl::stream::parseCalibration(pkt);
+
+            LOG(INFO) << "HAVE INTRIN: " << spec.intrinsics1.width << "x" << spec.intrinsics1.height;
+        }
+    });
+    inftl->set("looping", false);
+    inftl->begin(false);
+    inftl->tick(ftl::timer::get_time()+10000);  // Read 10 seconds of FTL file
+    inftl->end();
+
+    // Load a model using ASSIMP
+    auto *scene = ftl::create<ftl::render::AssimpScene>(root, "scene");
+	scene->set("model", root->value("model", std::string("")));
+	auto *render = ftl::create<ftl::render::AssimpRenderer>(root, "render");
+	render->setScene(scene);
+
+    // Generate OpenGL context and frame buffer
+    GLFWwindow *window;
+    if (!glfwInit()) {
+        LOG(ERROR) << "Could not init GL window";
+        return -1;
+    }
+
+    glfwWindowHint(GLFW_VISIBLE, GL_FALSE);
+    window = glfwCreateWindow(640, 480, "FTL", NULL, NULL);
+
+    if (!window) {
+        glfwTerminate();
+        LOG(ERROR) << "Could not create GL window";
+        return -1;
+    }
+
+    glfwMakeContextCurrent(window);
+
+	scene->load();
+	frames.resize(cameras.size());
+
+    // Render each view into framebuffer
+	for (size_t i=0; i<cameras.size(); ++i) {
+		render->setScene(scene);
+		render->begin(frames[i], Channel::Colour);
+		render->render();
+		render->end();
+	}
+
+    // Download each view and encode as JPG / PNG colour depth pairs
+
+    // Allow options to introduce distortion and noise into depth maps
+
+    // Use a ground truth channel?
+
+    glfwTerminate();
+    return 0;
+}
diff --git a/components/codecs/include/ftl/codecs/channels.hpp b/components/codecs/include/ftl/codecs/channels.hpp
index f2f8a833bfb2f5515a407dbcaf135929ebce4e63..7cd5827f33bc8d2506024e92873754e99e75c007 100644
--- a/components/codecs/include/ftl/codecs/channels.hpp
+++ b/components/codecs/include/ftl/codecs/channels.hpp
@@ -37,6 +37,7 @@ enum struct Channel : int {
 	RightHighRes	= 20,	// 8UC3 or 8UC4
 	Colour2HighRes	= 20,
 	Overlay			= 21,   // 8UC4
+	GroundTruth		= 22,	// 32F
 
 	Audio			= 32,
 	AudioMono		= 32,
@@ -55,7 +56,8 @@ enum struct Channel : int {
 	Data			= 2048,	// Custom data, any codec.
 	Faces			= 2049, // Data about detected faces
 	Transforms		= 2050,	// Transformation matrices for framesets
-	Shapes3D		= 2051	// Labeled 3D shapes
+	Shapes3D		= 2051,	// Labeled 3D shapes
+	Messages		= 2052	// Vector of Strings
 };
 
 inline bool isVideo(Channel c) { return (int)c < 32; };
@@ -147,6 +149,7 @@ static const Channels kAllChannels(0xFFFFFFFFu);
 
 inline bool isFloatChannel(ftl::codecs::Channel chan) {
 	switch (chan) {
+	case Channel::GroundTruth:
 	case Channel::Depth		:
 	//case Channel::Normals   :
 	case Channel::Confidence:
diff --git a/components/codecs/include/ftl/codecs/codecs.hpp b/components/codecs/include/ftl/codecs/codecs.hpp
index 5e594429e5dea928ebf1a92d2fc5c0a50f8fcc41..e2047b54ac1fcc86751a6e1da7b5f941cc19bad3 100644
--- a/components/codecs/include/ftl/codecs/codecs.hpp
+++ b/components/codecs/include/ftl/codecs/codecs.hpp
@@ -61,6 +61,8 @@ enum struct definition_t : uint8_t {
 
 	HTC_VIVE = 8,
 	OLD_SKOOL = 9,
+	MIDDLEBURY = 10,
+	MIDDLEBURY_HD = 11,
 
 	hz48000 = 32,
 	hz44100 = 33,
diff --git a/components/codecs/include/ftl/codecs/packet.hpp b/components/codecs/include/ftl/codecs/packet.hpp
index b0756178529a487e0203d440431ecafd0619a11b..546da9ac9e654757730284d269d6c56e305a3175 100644
--- a/components/codecs/include/ftl/codecs/packet.hpp
+++ b/components/codecs/include/ftl/codecs/packet.hpp
@@ -54,6 +54,8 @@ struct Packet {
 	MSGPACK_DEFINE(codec, definition, frame_count, bitrate, flags, data);
 };
 
+static constexpr unsigned int kStreamCap_Static = 0x01;
+
 /**
  * Add timestamp and channel information to a raw encoded frame packet. This
  * allows the packet to be located within a larger stream and should be sent
@@ -76,7 +78,9 @@ struct StreamPacket {
 	inline size_t frameSetID() const { return (version >= 4) ? streamID : 0; }
 	inline int64_t localTimestamp() const { return timestamp + originClockDelta; }
 
-	int64_t originClockDelta;  // Not message packet / saved
+	int64_t originClockDelta;  		// Not message packet / saved
+	unsigned int hint_capability;	// Is this a video stream, for example
+	size_t hint_source_total;		// Number of tracks per frame to expect
 
 	MSGPACK_DEFINE(timestamp, streamID, frame_number, channel);
 
diff --git a/components/codecs/src/bitrates.cpp b/components/codecs/src/bitrates.cpp
index b72b437d9b669badfc411c889a11a2db0fc0d7f4..654fb865d5549c91543adfda761bdde703dacf7a 100644
--- a/components/codecs/src/bitrates.cpp
+++ b/components/codecs/src/bitrates.cpp
@@ -24,6 +24,8 @@ static const Resolution resolutions[] = {
 	0, 0,			// ANY
 	1852, 2056,		// HTC_VIVE
 	640, 480,		// Old school 4:3
+	3000, 1920,		// Middlebury
+	1687, 1080,		// Middlebury smaller
 	0, 0
 };
 
diff --git a/components/codecs/src/channels.cpp b/components/codecs/src/channels.cpp
index 55426cb26513b28289a32c028d3067efa5ac54f5..75dc784fb09a6f9882f1a0156c09314641fdaef4 100644
--- a/components/codecs/src/channels.cpp
+++ b/components/codecs/src/channels.cpp
@@ -29,9 +29,9 @@ static ChannelInfo info[] = {
 	"ColourHighRes", 0,			// 17
 	"Disparity", 0,				// 18
 	"Smoothing", 0,				// 19
-	"NoName", 0,
-	"NoName", 0,
-	"NoName", 0,
+	"Colour2HighRes", 0,		// 20
+	"Overlay", 0,				// 21
+	"GroundTruth", CV_32F,		// 22
 	"NoName", 0,
 	"NoName", 0,
 	"NoName", 0,
diff --git a/components/codecs/src/opencv_decoder.cpp b/components/codecs/src/opencv_decoder.cpp
index 809f716dfc33eb2c6a2f23a52fb4c896e12ef3f0..416b7d90f72a4d8e5f9513de9a1ec9a9c4559db4 100644
--- a/components/codecs/src/opencv_decoder.cpp
+++ b/components/codecs/src/opencv_decoder.cpp
@@ -57,6 +57,8 @@ bool OpenCVDecoder::decode(const ftl::codecs::Packet &pkt, cv::cuda::GpuMat &out
 		} else if (!tmp_.empty() && tmp_.type() == CV_8UC4 && chunkHead.type() == CV_8UC4) {
 			//tmp_.copyTo(chunkHead);
 			chunkHead.upload(tmp_);
+		} else if (!tmp_.empty() && tmp_.type() == CV_16U && chunkHead.type() == CV_16U) {
+			chunkHead.upload(tmp_);
 		} else {
 			// Silent ignore?
 		}
diff --git a/components/codecs/src/opencv_encoder.cpp b/components/codecs/src/opencv_encoder.cpp
index 152f05754c619124b2c99a6f5876dc8016873a3b..b930d04294878f9c3a43d5187a82e9725841a5ca 100644
--- a/components/codecs/src/opencv_encoder.cpp
+++ b/components/codecs/src/opencv_encoder.cpp
@@ -38,14 +38,21 @@ bool OpenCVEncoder::encode(const cv::cuda::GpuMat &in, ftl::codecs::Packet &pkt)
 
 	pkt.definition = (pkt.definition == definition_t::Any) ? ftl::codecs::findDefinition(in.cols, in.rows) : pkt.definition;
 
-	if (pkt.definition == definition_t::Invalid || pkt.definition == definition_t::Any) return false;
+	if (pkt.definition == definition_t::Invalid || pkt.definition == definition_t::Any) {
+		LOG(ERROR) << "Invalid definition";
+		return false;
+	}
 
 	// Ensure definition does not exceed max
-	current_definition_ = ((int)pkt.definition < (int)max_definition) ? max_definition : pkt.definition;
+	current_definition_ = pkt.definition; //((int)pkt.definition < (int)max_definition) ? max_definition : pkt.definition;
 
 	in.download(tmp_);
 	//CHECK(cv::Size(ftl::codecs::getWidth(definition), ftl::codecs::getHeight(definition)) == in.size()); 
 
+	if (!is_colour) {
+		tmp_.convertTo(tmp_, CV_16U, 1000.0f);
+	}
+
 	int width = ftl::codecs::getWidth(current_definition_);
 	int height = ftl::codecs::getHeight(current_definition_);
 
@@ -55,7 +62,10 @@ bool OpenCVEncoder::encode(const cv::cuda::GpuMat &in, ftl::codecs::Packet &pkt)
 	} else {
 		
 	}*/
-	if (width != in.cols || height != in.rows) return false;
+	if (width != in.cols || height != in.rows) {
+		LOG(ERROR) << "Input does not match requested definition";
+		return false;
+	}
 
 	if (pkt.codec == codec_t::Any) pkt.codec = (is_colour) ? codec_t::JPG : codec_t::PNG;
 
diff --git a/components/common/cpp/include/ftl/configuration.hpp b/components/common/cpp/include/ftl/configuration.hpp
index 8ce55cd28198ae78b45a64efb6382c76d228a41f..bdbc4a5904b4161f3ec740bc02441dd1292caeab 100644
--- a/components/common/cpp/include/ftl/configuration.hpp
+++ b/components/common/cpp/include/ftl/configuration.hpp
@@ -22,6 +22,7 @@ bool is_directory(const std::string &path);
 bool is_file(const std::string &path);
 bool create_directory(const std::string &path);
 bool is_video(const std::string &file);
+std::vector<std::string> directory_listing(const std::string &path);
 
 namespace config {
 
diff --git a/components/common/cpp/src/configuration.cpp b/components/common/cpp/src/configuration.cpp
index 9a1b4e9cdd08f152e0b09647e7da7126d16197d6..0d2b79a97d3d7fb731e6fdb219540ad76dd5e35a 100644
--- a/components/common/cpp/src/configuration.cpp
+++ b/components/common/cpp/src/configuration.cpp
@@ -4,6 +4,7 @@
 
 #ifdef WIN32
 #include <windows.h>
+#pragma comment(lib, "User32.lib")
 #else
 #include <sys/types.h>
 #include <sys/stat.h>
@@ -12,6 +13,7 @@
 
 #ifndef WIN32
 #include <signal.h>
+#include <dirent.h>
 #endif
 
 #include <nlohmann/json.hpp>
@@ -88,6 +90,38 @@ bool ftl::is_file(const std::string &path) {
 #endif
 }
 
+std::vector<std::string> ftl::directory_listing(const std::string &path) {
+	std::vector<std::string> res;
+
+#ifdef WIN32
+	std::string path2 = path + "\\*";
+	WIN32_FIND_DATA ffd;
+	HANDLE hFind = FindFirstFile(path2.c_str(), &ffd);
+
+	if (hFind == INVALID_HANDLE_VALUE) return res;
+
+	do {
+		res.push_back(std::string(ffd.cFileName));
+	} while (FindNextFile(hFind, &ffd) != 0);
+
+	FindClose(hFind);
+	return res;
+#else
+	DIR *dir;
+	struct dirent *ent;
+	if ((dir = opendir (path.c_str())) != NULL) {
+		/* print all the files and directories within directory */
+		while ((ent = readdir (dir)) != NULL) {
+			res.push_back(path + std::string(ent->d_name));
+		}
+		closedir (dir);
+		return res;
+	} else {
+		return res;
+	}
+#endif
+}
+
 static bool endsWith(const string &s, const string &e) {
 	return s.size() >= e.size() && 
 				s.compare(s.size() - e.size(), e.size(), e) == 0;
diff --git a/components/operators/CMakeLists.txt b/components/operators/CMakeLists.txt
index ec80792076b0c4c4ff020e586d0f718b11926233..d0d786884525f45bbf3b4dcb41efda089d0bce89 100644
--- a/components/operators/CMakeLists.txt
+++ b/components/operators/CMakeLists.txt
@@ -31,6 +31,8 @@ set(OPERSRC
 	src/weighting.cpp
 	src/weighting.cu
 	src/poser.cpp
+	src/gt_analysis.cpp
+	src/gt_analysis.cu
 )
 
 if (LIBSGM_FOUND)
diff --git a/components/operators/include/ftl/operators/gt_analysis.hpp b/components/operators/include/ftl/operators/gt_analysis.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..9397a96da2ff80985d08d90d7f68697935fc895f
--- /dev/null
+++ b/components/operators/include/ftl/operators/gt_analysis.hpp
@@ -0,0 +1,31 @@
+#ifndef _FTL_OPERATORS_GTANALYSIS_HPP_
+#define _FTL_OPERATORS_GTANALYSIS_HPP_
+
+#include <ftl/operators/operator.hpp>
+#include <ftl/cuda_common.hpp>
+#include <ftl/operators/gt_cuda.hpp>
+
+namespace ftl {
+namespace operators {
+
+/**
+ * Ground Truth analysis, applys indications and metrics that compare any
+ * depth map with a ground truth channel (if both exist).
+ */
+class GTAnalysis : public ftl::operators::Operator {
+	public:
+    explicit GTAnalysis(ftl::Configurable*);
+    ~GTAnalysis();
+
+	inline Operator::Type type() const override { return Operator::Type::OneToOne; }
+
+    bool apply(ftl::rgbd::Frame &in, ftl::rgbd::Frame &out, cudaStream_t stream) override;
+
+	private:
+	ftl::cuda::GTAnalysisData *output_;
+};
+
+}
+}
+
+#endif  // _FTL_OPERATORS_GTANALYSIS_HPP_
diff --git a/components/operators/include/ftl/operators/gt_cuda.hpp b/components/operators/include/ftl/operators/gt_cuda.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..cd317a615e21e5dedf30c6d53f054e5564a075fc
--- /dev/null
+++ b/components/operators/include/ftl/operators/gt_cuda.hpp
@@ -0,0 +1,40 @@
+#ifndef _FTL_CUDA_GT_HPP_
+#define _FTL_CUDA_GT_HPP_
+
+#include <ftl/cuda_common.hpp>
+#include <ftl/rgbd/camera.hpp>
+
+namespace ftl {
+namespace cuda {
+
+struct GTAnalysisData {
+	int invalid;		// Count of invalid (missing depth)
+	int bad;			// Count bad (above x disparity error)
+	float totalerror;	// Summed disparity error (of valid values)
+	int masked;			// Count of pixels masked.
+};
+
+void gt_analysis(
+	ftl::cuda::TextureObject<uchar4> &colour,
+	ftl::cuda::TextureObject<float> &depth,
+	ftl::cuda::TextureObject<float> &gt,
+	ftl::cuda::GTAnalysisData *out,
+	const ftl::rgbd::Camera &cam,
+	float threshold,
+	float outmax,
+	cudaStream_t stream
+);
+
+void gt_analysis(
+	ftl::cuda::TextureObject<float> &depth,
+	ftl::cuda::TextureObject<float> &gt,
+	ftl::cuda::GTAnalysisData *out,
+	const ftl::rgbd::Camera &cam,
+	float threshold,
+	cudaStream_t stream
+);
+
+}
+}
+
+#endif 
\ No newline at end of file
diff --git a/components/operators/src/disparity/disp2depth.cu b/components/operators/src/disparity/disp2depth.cu
index 349920dd2d21f8a330c8f2de7ce6aea5e9672185..1e655e2d6429a3c11279dfd4423c0bfdbdff92e1 100644
--- a/components/operators/src/disparity/disp2depth.cu
+++ b/components/operators/src/disparity/disp2depth.cu
@@ -2,6 +2,10 @@
 #include <ftl/rgbd/camera.hpp>
 #include <opencv2/core/cuda_stream_accessor.hpp>
 
+#ifndef PINF
+#define PINF __int_as_float(0x7f800000)
+#endif
+
 __global__ void d2d_kernel(cv::cuda::PtrStepSz<float> disp, cv::cuda::PtrStepSz<float> depth,
 		ftl::rgbd::Camera cam) {
 
diff --git a/components/operators/src/gt_analysis.cpp b/components/operators/src/gt_analysis.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..91d59ca3d362a0f59580773b11fa0a9d7e3ac405
--- /dev/null
+++ b/components/operators/src/gt_analysis.cpp
@@ -0,0 +1,70 @@
+#include <ftl/operators/gt_analysis.hpp>
+#include <ftl/operators/gt_cuda.hpp>
+
+using ftl::operators::GTAnalysis;
+using ftl::codecs::Channel;
+using std::string;
+
+GTAnalysis::GTAnalysis(ftl::Configurable *cfg) : ftl::operators::Operator(cfg) {
+	cudaMalloc(&output_, sizeof(ftl::cuda::GTAnalysisData));
+}
+
+GTAnalysis::~GTAnalysis() {
+	cudaFree(output_);
+}
+
+template <typename T>
+std::string to_string_with_precision(const T a_value, const int n = 6) {
+    std::ostringstream out;
+    out.precision(n);
+    out << std::fixed << a_value;
+    return out.str();
+}
+
+bool GTAnalysis::apply(ftl::rgbd::Frame &in, ftl::rgbd::Frame &out, cudaStream_t stream) {
+	if (in.hasChannel(Channel::Depth) && in.hasChannel(Channel::GroundTruth)) {
+		if (config()->value("show_colour", false)) {
+			ftl::cuda::gt_analysis(
+				in.createTexture<uchar4>(Channel::Colour),
+				in.createTexture<float>(Channel::Depth),
+				in.createTexture<float>(Channel::GroundTruth),
+				output_,
+				in.getLeft(),
+				config()->value("bad_threshold", 2.0f),
+				config()->value("viz_threshold", 5.0f),
+				stream
+			);
+		} else {
+			ftl::cuda::gt_analysis(
+				in.createTexture<float>(Channel::Depth),
+				in.createTexture<float>(Channel::GroundTruth),
+				output_,
+				in.getLeft(),
+				config()->value("bad_threshold", 2.0f),
+				stream
+			);
+		}
+
+		ftl::cuda::GTAnalysisData anal;
+		cudaMemcpy(&anal, output_, sizeof(anal), cudaMemcpyDeviceToHost);
+
+		auto &dmat = in.get<cv::cuda::GpuMat>(Channel::Depth);
+		int totalvalid = dmat.cols*dmat.rows - anal.invalid - anal.masked;
+		//int totaltested = dmat.cols*dmat.rows - anal.masked;
+
+		float pbad = float(anal.bad) / float(totalvalid) * 100.0f;
+		float pinvalid = float(anal.invalid) / float(dmat.cols*dmat.rows - anal.masked) * 100.0f;
+		float avgerr = anal.totalerror / float(totalvalid) * 100.0f;
+
+		std::vector<std::string> msgs;
+		if (in.hasChannel(Channel::Messages)) in.get(Channel::Messages, msgs);
+
+		msgs.push_back(string("Bad %: ") + to_string_with_precision(pbad, 1));
+		msgs.push_back(string("Invalid %: ") + to_string_with_precision(pinvalid,1));
+		msgs.push_back(string("Avg Error: ") + to_string_with_precision(avgerr, 2));
+
+		in.create(Channel::Messages, msgs);
+	}
+
+	return true;
+}
diff --git a/components/operators/src/gt_analysis.cu b/components/operators/src/gt_analysis.cu
new file mode 100644
index 0000000000000000000000000000000000000000..230f2fd20c44dce8e6db125434d6801b69183cef
--- /dev/null
+++ b/components/operators/src/gt_analysis.cu
@@ -0,0 +1,185 @@
+#include <ftl/operators/gt_cuda.hpp>
+
+#ifndef WARP_SIZE
+#define WARP_SIZE 32
+#endif
+
+#define FULL_MASK 0xffffffff
+
+template <bool COLOUR>
+__global__ void gt_anal_kernel(
+	uchar4* __restrict__ colour,
+	int cpitch,
+	int width,
+	int height,
+	const float* __restrict__ depth,
+	int dpitch,
+	const float* __restrict__ gt,
+	int gpitch,
+	ftl::cuda::GTAnalysisData *out,
+	ftl::rgbd::Camera cam,
+	float threshold,
+	float outmax
+) {
+
+	__shared__ int sinvalid;
+	__shared__ int sbad;
+	__shared__ int smasked;
+	__shared__ float serr;
+
+	if (threadIdx.x == 0 && threadIdx.y == 0) {
+		sinvalid = 0;
+		sbad = 0;
+		smasked = 0;
+		serr = 0.0f;
+	}
+	__syncthreads();
+
+	const unsigned int x = blockIdx.x*blockDim.x + threadIdx.x;
+
+	int invalid = 0;
+	int bad = 0;
+	int masked = 0;
+	float err = 0.0f;
+
+	const float numer = cam.baseline*cam.fx;
+
+	if (x < width) {
+		const float* __restrict__ gt_ptr = gt+x;
+		const float* __restrict__ d_ptr = depth+x;
+
+		for (STRIDE_Y(y, height)) {
+			// TODO: Verify gt and depth pitch are same
+			float gtval = gt_ptr[y*dpitch];
+			float dval = d_ptr[y*dpitch];
+
+			const int tmasked = (gtval > cam.minDepth && gtval < cam.maxDepth) ? 0 : 1;
+			const int tinvalid = (tmasked == 0 && (dval <= cam.minDepth || dval >= cam.maxDepth)) ? 1 : 0;
+
+			uchar4 c = make_uchar4((tinvalid==1)?255:0,0,0,255);
+
+			// Convert both to disparity...
+			if (tinvalid == 0 && tmasked == 0) {
+				dval = (numer / dval);
+				gtval = (numer / gtval);
+
+				const float e = fabsf(dval-gtval);
+				bad += (e >= threshold) ? 1 : 0;
+				err += e;
+
+				if (COLOUR) {
+					float nerr = min(1.0f, e / outmax);
+					c.z = min(255.0f, 255.0f * nerr);
+				}
+			}
+
+			invalid += tinvalid;
+			masked += tmasked;
+
+			if (COLOUR) colour[x+y*cpitch] = c;
+		}
+	}
+
+	// Warp aggregate
+	#pragma unroll
+	for (int i = WARP_SIZE/2; i > 0; i /= 2) {
+		bad += __shfl_xor_sync(FULL_MASK, bad, i, WARP_SIZE);
+		invalid += __shfl_xor_sync(FULL_MASK, invalid, i, WARP_SIZE);
+		masked += __shfl_xor_sync(FULL_MASK, masked, i, WARP_SIZE);
+		err += __shfl_xor_sync(FULL_MASK, err, i, WARP_SIZE);
+	}
+
+	// Block aggregate
+	if (threadIdx.x % WARP_SIZE == 0) {
+		atomicAdd(&serr, err);
+		atomicAdd(&sbad, bad);
+		atomicAdd(&sinvalid, invalid);
+		atomicAdd(&smasked, masked);
+	}
+
+	__syncthreads();
+
+	// Global aggregate
+	if (threadIdx.x == 0 && threadIdx.y == 0) {
+		atomicAdd(&out->totalerror, serr);
+		atomicAdd(&out->bad, sbad);
+		atomicAdd(&out->invalid, sinvalid);
+		atomicAdd(&out->masked, smasked);
+	}
+}
+
+void ftl::cuda::gt_analysis(
+	ftl::cuda::TextureObject<uchar4> &colour,
+	ftl::cuda::TextureObject<float> &depth,
+	ftl::cuda::TextureObject<float> &gt,
+	ftl::cuda::GTAnalysisData *out,
+	const ftl::rgbd::Camera &cam,
+	float threshold,
+	float outmax,
+	cudaStream_t stream
+) {
+	static constexpr int THREADS_X = 128;
+	static constexpr int THREADS_Y = 2;
+
+	const dim3 gridSize((depth.width() + THREADS_X - 1)/THREADS_X,16);
+	const dim3 blockSize(THREADS_X, THREADS_Y);
+
+	cudaMemsetAsync(out, 0, sizeof(ftl::cuda::GTAnalysisData), stream);
+
+	gt_anal_kernel<true><<<gridSize, blockSize, 0, stream>>>(
+		colour.devicePtr(),
+		colour.pixelPitch(),
+		colour.width(),
+		colour.height(),
+		depth.devicePtr(),
+		depth.pixelPitch(),
+		gt.devicePtr(),
+		gt.pixelPitch(),
+		out,
+		cam,
+		threshold,
+		outmax
+	);
+	cudaSafeCall( cudaGetLastError() );
+
+	#ifdef _DEBUG
+	cudaSafeCall(cudaDeviceSynchronize());
+	#endif
+}
+
+void ftl::cuda::gt_analysis(
+	ftl::cuda::TextureObject<float> &depth,
+	ftl::cuda::TextureObject<float> &gt,
+	ftl::cuda::GTAnalysisData *out,
+	const ftl::rgbd::Camera &cam,
+	float threshold,
+	cudaStream_t stream
+) {
+	static constexpr int THREADS_X = 128;
+	static constexpr int THREADS_Y = 2;
+
+	const dim3 gridSize((depth.width() + THREADS_X - 1)/THREADS_X, 16);
+	const dim3 blockSize(THREADS_X, THREADS_Y);
+
+	cudaMemsetAsync(out, 0, sizeof(ftl::cuda::GTAnalysisData), stream);
+
+	gt_anal_kernel<false><<<gridSize, blockSize, 0, stream>>>(
+		nullptr,
+		0,
+		depth.width(),
+		depth.height(),
+		depth.devicePtr(),
+		depth.pixelPitch(),
+		gt.devicePtr(),
+		gt.pixelPitch(),
+		out,
+		cam,
+		threshold,
+		1.0f
+	);
+	cudaSafeCall( cudaGetLastError() );
+
+	#ifdef _DEBUG
+	cudaSafeCall(cudaDeviceSynchronize());
+	#endif
+}
\ No newline at end of file
diff --git a/components/operators/src/weighting.cpp b/components/operators/src/weighting.cpp
index c1c57aa51739b6285248176c5d4ca7db36101917..5445d6786de3bc04bd515315910740a49d35290a 100644
--- a/components/operators/src/weighting.cpp
+++ b/components/operators/src/weighting.cpp
@@ -33,27 +33,29 @@ bool PixelWeights::apply(ftl::rgbd::Frame &in, ftl::rgbd::Frame &out, cudaStream
 	params.normals = config()->value("use_normals", true);
 	bool output_normals = config()->value("output_normals", params.normals);
 
-	if (!in.hasChannel(Channel::Depth) || !in.hasChannel(Channel::Support1)) return false;
+	if ((!in.hasChannel(Channel::Depth) && !in.hasChannel(Channel::GroundTruth)) || !in.hasChannel(Channel::Support1)) return false;
+
+	Channel dchan = (in.hasChannel(Channel::Depth)) ? Channel::Depth : Channel::GroundTruth;
 
 	if (output_normals) {
 		ftl::cuda::pixel_weighting(
-			out.createTexture<short>(Channel::Weights, ftl::rgbd::Format<short>(in.get<cv::cuda::GpuMat>(Channel::Depth).size())),
-			out.createTexture<uint8_t>(Channel::Mask, ftl::rgbd::Format<uint8_t>(in.get<cv::cuda::GpuMat>(Channel::Depth).size())),
-			out.createTexture<half4>(Channel::Normals, ftl::rgbd::Format<half4>(in.get<cv::cuda::GpuMat>(Channel::Depth).size())),
+			out.createTexture<short>(Channel::Weights, ftl::rgbd::Format<short>(in.get<cv::cuda::GpuMat>(dchan).size())),
+			out.createTexture<uint8_t>(Channel::Mask, ftl::rgbd::Format<uint8_t>(in.get<cv::cuda::GpuMat>(dchan).size())),
+			out.createTexture<half4>(Channel::Normals, ftl::rgbd::Format<half4>(in.get<cv::cuda::GpuMat>(dchan).size())),
 			in.createTexture<uchar4>(Channel::Support1),
-			in.createTexture<float>(Channel::Depth),
+			in.createTexture<float>(dchan),
 			in.getLeftCamera(),
-			in.get<cv::cuda::GpuMat>(Channel::Depth).size(),
+			in.get<cv::cuda::GpuMat>(dchan).size(),
 			params, stream
 		);
 	} else {
 		ftl::cuda::pixel_weighting(
-			out.createTexture<short>(Channel::Weights, ftl::rgbd::Format<short>(in.get<cv::cuda::GpuMat>(Channel::Depth).size())),
-			out.createTexture<uint8_t>(Channel::Mask, ftl::rgbd::Format<uint8_t>(in.get<cv::cuda::GpuMat>(Channel::Depth).size())),
+			out.createTexture<short>(Channel::Weights, ftl::rgbd::Format<short>(in.get<cv::cuda::GpuMat>(dchan).size())),
+			out.createTexture<uint8_t>(Channel::Mask, ftl::rgbd::Format<uint8_t>(in.get<cv::cuda::GpuMat>(dchan).size())),
 			in.createTexture<uchar4>(Channel::Support1),
-			in.createTexture<float>(Channel::Depth),
+			in.createTexture<float>(dchan),
 			in.getLeftCamera(),
-			in.get<cv::cuda::GpuMat>(Channel::Depth).size(),
+			in.get<cv::cuda::GpuMat>(dchan).size(),
 			params, stream
 		);
 	}
diff --git a/components/renderers/cpp/CMakeLists.txt b/components/renderers/cpp/CMakeLists.txt
index 9706be7c62bc97045431b213cf0388c66d3a3b51..8c5c1f7f078de8761c676627012862857fbbab63 100644
--- a/components/renderers/cpp/CMakeLists.txt
+++ b/components/renderers/cpp/CMakeLists.txt
@@ -10,6 +10,8 @@ add_library(ftlrender
 	src/colouriser.cpp
 	src/colour_util.cu
 	src/overlay.cpp
+	#src/assimp_render.cpp
+	#src/assimp_scene.cpp
 )
 
 # Various preprocessor definitions have been generated by NanoGUI
diff --git a/components/renderers/cpp/include/ftl/render/CUDARender.hpp b/components/renderers/cpp/include/ftl/render/CUDARender.hpp
index 25f0ffaa608354e4af63c9de128f5a38621d74f4..cfdb4cf4016ef00f6df3b9c37adbd6c26598c813 100644
--- a/components/renderers/cpp/include/ftl/render/CUDARender.hpp
+++ b/components/renderers/cpp/include/ftl/render/CUDARender.hpp
@@ -17,7 +17,7 @@ class Colouriser;
  * Generate triangles between connected points and render those. Colour is done
  * by weighted reprojection to the original source images.
  */
-class CUDARender : public ftl::render::Renderer {
+class CUDARender : public ftl::render::FSRenderer {
 	public:
 	explicit CUDARender(nlohmann::json &config);
 	~CUDARender();
@@ -28,6 +28,8 @@ class CUDARender : public ftl::render::Renderer {
 	bool submit(ftl::rgbd::FrameSet *in, ftl::codecs::Channels<0>, const Eigen::Matrix4d &t) override;
 	//void setOutputDevice(int);
 
+	void render() override;
+
 	void blend(ftl::codecs::Channel) override;
 
 	void setViewPort(ftl::render::ViewPortMode mode, const ftl::render::ViewPort &vp) {
diff --git a/components/renderers/cpp/include/ftl/render/assimp_render.hpp b/components/renderers/cpp/include/ftl/render/assimp_render.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..b34c93f5dc781c9236b55c3158515c18f773bee1
--- /dev/null
+++ b/components/renderers/cpp/include/ftl/render/assimp_render.hpp
@@ -0,0 +1,50 @@
+#ifndef _FTL_RENDER_ASSIMP_HPP_
+#define _FTL_RENDER_ASSIMP_HPP_
+
+#include <ftl/render/renderer.hpp>
+#include <ftl/render/assimp_scene.hpp>
+//#include <GL/gl.h>
+//#include <GL/glext.h>
+#include <nanogui/glutil.h>
+
+namespace ftl {
+namespace render {
+
+/**
+ * Render Assimp library models using OpenGL into a frame object. The
+ * channels will be OpenGL pixel buffer objects, or should be created as such
+ * before hand. Begin, end, render etc must also be called in a valid OpenGL
+ * context.
+ */
+class AssimpRenderer : public ftl::render::Renderer {
+	public:
+	explicit AssimpRenderer(nlohmann::json &config);
+	~AssimpRenderer();
+
+	void begin(ftl::rgbd::Frame &, ftl::codecs::Channel) override;
+
+	void end() override;
+
+	void render() override;
+
+	void blend(ftl::codecs::Channel) override;
+
+	void setScene(ftl::render::AssimpScene *);
+
+	/**
+	 * Set the pose / model view matix for the scene not the camera.
+	 */
+	void setPose(const Eigen::Matrix4d &pose);
+
+	private:
+	AssimpScene *scene_;
+	ftl::rgbd::Frame *out_;
+	ftl::codecs::Channel outchan_;
+	nanogui::GLShader shader_;
+	bool init_;
+};
+
+}
+}
+
+#endif
diff --git a/components/renderers/cpp/include/ftl/render/assimp_scene.hpp b/components/renderers/cpp/include/ftl/render/assimp_scene.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..f07fd424a65d3704a3da8499d3408b7ae2ceed5a
--- /dev/null
+++ b/components/renderers/cpp/include/ftl/render/assimp_scene.hpp
@@ -0,0 +1,50 @@
+#ifndef _FTL_RENDER_ASSIMPSCENE_HPP_
+#define _FTL_RENDER_ASSIMPSCENE_HPP_
+
+#include <ftl/configurable.hpp>
+#include <assimp/scene.h>
+#include <nanogui/glutil.h>
+#include <Eigen/Eigen>
+
+#include <vector>
+#include <map>
+#include <unordered_map>
+
+namespace ftl {
+namespace render {
+
+struct GLMesh {
+	int material;
+	std::vector<int> indices;
+	Eigen::Matrix4d transform;
+};
+
+struct VBOData {
+	float vertex[3];
+	float normal[3];
+	unsigned char colour[4];
+	short uv[2];
+};
+
+class AssimpScene : public ftl::Configurable {
+	public:
+	explicit AssimpScene(nlohmann::json &config);
+	~AssimpScene();
+
+	void load();
+
+	const aiScene *scene;
+	std::map<std::string, GLuint*> textureIdMap;	// map image filenames to textureIds
+	std::vector<GLuint> textureIds;
+	std::vector<VBOData> vbo;  // Combined vertex data
+	std::vector<GLMesh> meshes; // map materials to indicies vectors
+
+	private:
+	void _freeTextureIds();
+	bool _loadGLTextures();
+};
+
+}
+}
+
+#endif
diff --git a/components/renderers/cpp/include/ftl/render/renderer.hpp b/components/renderers/cpp/include/ftl/render/renderer.hpp
index 2273220cfcf198067c25d1a3d329a2cb4558b8d1..a7678b92fbb294c91169f28fa117950c6108a499 100644
--- a/components/renderers/cpp/include/ftl/render/renderer.hpp
+++ b/components/renderers/cpp/include/ftl/render/renderer.hpp
@@ -43,7 +43,23 @@ class Renderer : public ftl::Configurable {
 	 */
 	virtual void end()=0;
 
-    /**
+	virtual void render()=0;
+
+	virtual void blend(ftl::codecs::Channel)=0;
+
+	protected:
+	Stage stage_;
+};
+
+/**
+ * A renderer specifically for RGB-D framesets.
+ */
+class FSRenderer : public ftl::render::Renderer {
+	public:
+	explicit FSRenderer(nlohmann::json &config) : ftl::render::Renderer(config) {};
+	virtual ~FSRenderer() {};
+
+	/**
      * Render all frames of a frameset into the output frame. This can be called
 	 * multiple times between `begin` and `end` to combine multiple framesets.
 	 * Note that the frameset pointer must remain valid until `end` is called,
@@ -55,11 +71,6 @@ class Renderer : public ftl::Configurable {
 	 * to RGB colour appropriately.
      */
     virtual bool submit(ftl::rgbd::FrameSet *, ftl::codecs::Channels<0>, const Eigen::Matrix4d &)=0;
-
-	virtual void blend(ftl::codecs::Channel)=0;
-
-	protected:
-	Stage stage_;
 };
 
 }
diff --git a/components/renderers/cpp/include/ftl/render/vbo.hpp b/components/renderers/cpp/include/ftl/render/vbo.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..ab7ab6ba1530cb30a703226640fdd43caec1be7c
--- /dev/null
+++ b/components/renderers/cpp/include/ftl/render/vbo.hpp
@@ -0,0 +1,40 @@
+#ifndef _FTL_RENDER_VBO_HPP_
+#define _FTL_RENDER_VBO_HPP_
+
+namespace ftl {
+namespace render {
+
+/**
+ * OpenGL Vertex Buffer Object wrapper.
+ */
+class VBO {
+	public:
+	VBO();
+	~VBO();
+
+	void begin();
+	void end();
+
+	void bind();
+	void unbind();
+
+	void writeVertex3D(float x, float y, float z);
+	void writeVertex3D(const float3 &v);
+	void writeVertex3D(const Eigen::Vertex3f &v);
+	void writeVertices3D(float *v, size_t c);
+	void writeVertices3D(float3 *v, size_t c);
+
+	void writeColour(uchar r, uchar g, uchar b, uchar a);
+	void writeColour(uchar4 c);
+	void writeColour(float r, float g, float b, float a);
+	void writeColours(float *v, size_t);
+	void writeColours(uchar4 *c, size_t);
+
+	void writeTexCoords(float u, float v);
+	void writeTexCoords(const float2 &uv);
+};
+
+}
+}
+
+#endif
\ No newline at end of file
diff --git a/components/renderers/cpp/src/CUDARender.cpp b/components/renderers/cpp/src/CUDARender.cpp
index 9fb3c045c66bbefa8fa7adaf820d9b8662ba7098..c2673073bf35d959a6c79441be8a67155b94a8e6 100644
--- a/components/renderers/cpp/src/CUDARender.cpp
+++ b/components/renderers/cpp/src/CUDARender.cpp
@@ -31,7 +31,7 @@ using ftl::cuda::Mask;
 using ftl::render::parseCUDAColour;
 using ftl::render::parseCVColour;
 
-CUDARender::CUDARender(nlohmann::json &config) : ftl::render::Renderer(config), scene_(nullptr) {
+CUDARender::CUDARender(nlohmann::json &config) : ftl::render::FSRenderer(config), scene_(nullptr) {
 	/*if (config["clipping"].is_object()) {
 		auto &c = config["clipping"];
 		float rx = c.value("pitch", 0.0f);
@@ -139,6 +139,19 @@ void CUDARender::_renderChannel(ftl::rgbd::Frame &output, ftl::codecs::Channel i
 				f.getLeftCamera(),
 				transform, transformR, stream
 			);
+		} else if (f.hasChannel(Channel::GroundTruth)) {
+			ftl::cuda::reproject(
+				texture,
+				f.createTexture<float>(Channel::GroundTruth),
+				output.getTexture<float>(_getDepthChannel()),
+				f.createTexture<short>(Channel::Weights),
+				(mesh_) ? &output.getTexture<half4>(_getNormalsChannel()) : nullptr,
+				accum_,
+				contrib_,
+				params_,
+				f.getLeftCamera(),
+				transform, transformR, stream
+			);
 		} else {
 			// Reproject without depth channel or normals
 			ftl::cuda::reproject(
@@ -254,6 +267,13 @@ void CUDARender::_mesh(ftl::rgbd::Frame &out, const Eigen::Matrix4d &t, cudaStre
 				screenbuffer,
 				params_, transform, f.getLeftCamera(), stream
 			);
+		} else if (f.hasChannel(Channel::GroundTruth)) {
+			ftl::cuda::screen_coord(
+				f.createTexture<float>(Channel::GroundTruth),
+				depthbuffer,
+				screenbuffer,
+				params_, transform, f.getLeftCamera(), stream
+			);
 		} else {
 			// Constant depth version
 			ftl::cuda::screen_coord(
@@ -484,13 +504,19 @@ void CUDARender::begin(ftl::rgbd::Frame &out, ftl::codecs::Channel chan) {
 	stage_ = Stage::ReadySubmit;
 }
 
-void CUDARender::blend(Channel c) {
-	if (stage_ == Stage::Finished) {
-		throw FTL_Error("Cannot call blend at this time");
-	} else if (stage_ == Stage::ReadySubmit) {
+void CUDARender::render() {
+	if (stage_ != Stage::ReadySubmit) {
+		throw FTL_Error("Cannot call render at this time");
+	} else {
 		stage_ = Stage::Blending;
 		_endSubmit();
 	}
+}
+
+void CUDARender::blend(Channel c) {
+	if (stage_ != Stage::Blending) {
+		throw FTL_Error("Cannot call blend at this time");
+	}
 
 	cv::cuda::Stream cvstream = cv::cuda::StreamAccessor::wrapStream(stream_);
 
diff --git a/components/renderers/cpp/src/assimp_render.cpp b/components/renderers/cpp/src/assimp_render.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c199ca9160061556518840fac4280749d18bab23
--- /dev/null
+++ b/components/renderers/cpp/src/assimp_render.cpp
@@ -0,0 +1,112 @@
+#include <ftl/render/assimp_render.hpp>
+
+#include <assimp/Importer.hpp>
+#include <assimp/postprocess.h>
+#include <assimp/scene.h>
+#include <GL/gl.h>
+
+using ftl::render::AssimpRenderer;
+
+namespace {
+	constexpr char const *const assimpVertexShader =
+		R"(#version 330
+		in vec3 vertex;
+		uniform float focal;
+		uniform float width;
+		uniform float height;
+		uniform float far;
+		uniform float near;
+        uniform mat4 pose;
+        uniform vec3 scale;
+
+		void main() {
+            vec4 vert = pose*(vec4(scale*vertex,1.0));
+            vert = vert / vert.w;
+			//vec4 pos = vec4(-vert.x*focal / -vert.z / (width/2.0),
+			//	vert.y*focal / -vert.z / (height/2.0),
+			//	(vert.z-near) / (far-near) * 2.0 - 1.0, 1.0);
+
+			vec4 pos = vec4(
+				vert.x*focal / (width/2.0),
+				-vert.y*focal / (height/2.0),
+				-vert.z * ((far+near) / (far-near)) + (2.0 * near * far / (far-near)),
+				//((vert.z - near) / (far - near) * 2.0 - 1.0) * vert.z,
+				vert.z
+			);
+
+			gl_Position = pos;
+		})";
+
+	constexpr char const *const assimpFragmentShader =
+		R"(#version 330
+		uniform vec4 blockColour;
+		out vec4 color;
+		
+		void main() {
+			color = blockColour;
+		})";
+}
+
+AssimpRenderer::AssimpRenderer(nlohmann::json &config) : ftl::render::Renderer(config) {
+	init_ = false;
+}
+
+AssimpRenderer::~AssimpRenderer() {
+
+}
+
+void AssimpRenderer::begin(ftl::rgbd::Frame &f, ftl::codecs::Channel c) {
+	if (!scene_) {
+		throw FTL_Error("No Assimp scene");
+	}
+
+	out_ = &f;
+	outchan_ = c;
+
+	if (!init_) {
+		shader_.init("AssimpShader", assimpVertexShader, assimpFragmentShader);
+        shader_.bind();
+		init_ = true;
+	} else {
+	    shader_.bind();
+    }
+
+	const auto &intrin = f.getLeftCamera();
+
+	glViewport(0, 0, intrin.width, intrin.height);					// Reset The Current Viewport
+
+	glMatrixMode(GL_PROJECTION);						// Select The Projection Matrix
+	glLoadIdentity();							// Reset The Projection Matrix
+
+	//gluPerspective(2.0f*atan(0.5f*float(intrin.height) / intrin.fy),(GLfloat)intrin.width/(GLfloat)intrin.height,intrin.minDepth,intrin.maxDepth);
+
+	glMatrixMode(GL_MODELVIEW);						// Select The Modelview Matrix
+	glLoadIdentity();
+
+	glEnable(GL_TEXTURE_2D);
+	glShadeModel(GL_SMOOTH);		 // Enables Smooth Shading
+	glClearColor(1.0f, 1.0f, 1.0f, 0.0f);
+	glClearDepth(1.0f);				// Depth Buffer Setup
+	glEnable(GL_DEPTH_TEST);		// Enables Depth Testing
+	glDepthFunc(GL_LEQUAL);			// The Type Of Depth Test To Do
+
+	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+
+	scene_->load();
+}
+
+void AssimpRenderer::end() {
+	glFlush();
+}
+
+void AssimpRenderer::render() {
+
+}
+
+void AssimpRenderer::blend(ftl::codecs::Channel) {
+	throw FTL_Error("Blend not supported in Assimp render");
+}
+
+void AssimpRenderer::setScene(ftl::render::AssimpScene *scene) {
+	scene_ = scene;
+}
diff --git a/components/renderers/cpp/src/assimp_scene.cpp b/components/renderers/cpp/src/assimp_scene.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9b2adf294a82912994c72faa9f5ef75276215f96
--- /dev/null
+++ b/components/renderers/cpp/src/assimp_scene.cpp
@@ -0,0 +1,133 @@
+#include <ftl/render/assimp_scene.hpp>
+
+#include <assimp/Importer.hpp>
+#include <assimp/postprocess.h>
+#include <assimp/scene.h>
+
+#include <opencv2/imgproc.hpp>
+#include <opencv2/imgcodecs.hpp>
+
+using ftl::render::AssimpScene;
+using std::string;
+
+static std::string getBasePath(const std::string& path) {
+	size_t pos = path.find_last_of("\\/");
+	return (std::string::npos == pos) ? "" : path.substr(0, pos + 1);
+}
+
+AssimpScene::AssimpScene(nlohmann::json &config) : ftl::Configurable(config) {
+
+}
+
+AssimpScene::~AssimpScene() {
+	_freeTextureIds();
+}
+
+void AssimpScene::load() {
+	if (scene) return;
+
+	Assimp::Importer importer;
+    scene = importer.ReadFile(value("model", std::string("")), aiProcessPreset_TargetRealtime_Quality);
+
+    if (!scene) {
+        throw FTL_Error("Could not load model: " << value("model", string("")));
+    }
+
+	_loadGLTextures();
+
+	for (size_t n=0; n<scene->mNumMeshes; ++n) {
+		const aiMesh* mesh = scene->mMeshes[n];
+
+		vbo.reserve(vbo.size()+mesh->mNumVertices);
+		for (size_t i=0; i<mesh->mNumVertices; ++i) {
+			//vertices.push_back(mesh->mVertices)
+		}
+	}
+}
+
+void AssimpScene::_freeTextureIds() {
+	textureIdMap.clear();
+}
+
+bool AssimpScene::_loadGLTextures() {
+	_freeTextureIds();
+
+    if (scene->HasTextures()) return true;
+
+	/* getTexture Filenames and Numb of Textures */
+	for (unsigned int m=0; m<scene->mNumMaterials; m++)
+	{
+		int texIndex = 0;
+		aiReturn texFound = AI_SUCCESS;
+
+		aiString path;	// filename
+
+		while (texFound == AI_SUCCESS)
+		{
+			texFound = scene->mMaterials[m]->GetTexture(aiTextureType_DIFFUSE, texIndex, &path);
+			textureIdMap[path.data] = NULL; //fill map with textures, pointers still NULL yet
+			texIndex++;
+		}
+	}
+
+	const size_t numTextures = textureIdMap.size();
+
+	/* create and fill array with GL texture ids */
+	//textureIds = new GLuint[numTextures];
+	textureIds.resize(numTextures);
+	glGenTextures(static_cast<GLsizei>(numTextures), textureIds.data()); /* Texture name generation */
+
+	/* get iterator */
+	std::map<std::string, GLuint*>::iterator itr = textureIdMap.begin();
+
+	std::string basepath = getBasePath(value("model", std::string("")));
+	for (size_t i=0; i<numTextures; i++) {
+
+		//save IL image ID
+		std::string filename = (*itr).first;  // get filename
+		(*itr).second =  &textureIds[i];	  // save texture id for filename in map
+		++itr;								  // next texture
+
+
+		//ilBindImage(imageIds[i]); /* Binding of DevIL image name */
+		std::string fileloc = basepath + filename;	/* Loading of image */
+		//success = ilLoadImage(fileloc.c_str());
+        int x, y, n;
+		cv::Mat img = cv::imread(fileloc);
+        unsigned char *data = img.data;
+
+		if (!img.empty()) {
+            // Convert every colour component into unsigned byte.If your image contains
+            // alpha channel you can replace IL_RGB with IL_RGBA
+            //success = ilConvertImage(IL_RGB, IL_UNSIGNED_BYTE);
+			/*if (!success)
+			{
+				abortGLInit("Couldn't convert image");
+				return -1;
+			}*/
+            // Binding of texture name
+            glBindTexture(GL_TEXTURE_2D, textureIds[i]);
+			// redefine standard texture values
+            // We will use linear interpolation for magnification filter
+            glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
+            // We will use linear interpolation for minifying filter
+            glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
+            // Texture specification
+            glTexImage2D(GL_TEXTURE_2D, 0, n, x, y, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);// Texture specification.
+
+            // we also want to be able to deal with odd texture dimensions
+            glPixelStorei( GL_UNPACK_ALIGNMENT, 1 );
+            glPixelStorei( GL_UNPACK_ROW_LENGTH, 0 );
+            glPixelStorei( GL_UNPACK_SKIP_PIXELS, 0 );
+            glPixelStorei( GL_UNPACK_SKIP_ROWS, 0 );
+        }
+		else
+		{
+			/* Error occurred */
+			//MessageBox(NULL, UTFConverter("Couldn't load Image: " + fileloc).c_wstr(), TEXT("ERROR"), MB_OK | MB_ICONEXCLAMATION);
+			throw FTL_Error("Could no load texture image: " << fileloc);
+		}
+	}
+
+	return true;
+}
\ No newline at end of file
diff --git a/components/renderers/cpp/src/colouriser.cpp b/components/renderers/cpp/src/colouriser.cpp
index 69ad566a20ea391c32fea468ab5e7cb38ef63027..be92ed854f5699bf2b20883ce6d8424094371659 100644
--- a/components/renderers/cpp/src/colouriser.cpp
+++ b/components/renderers/cpp/src/colouriser.cpp
@@ -118,6 +118,7 @@ TextureObject<uchar4> &Colouriser::colourise(ftl::rgbd::Frame &f, Channel c, cud
 	case Channel::ColourHighRes	:
 	case Channel::Colour		:
 	case Channel::Colour2		: return _processColour(f,c,stream);
+	case Channel::GroundTruth	:
 	case Channel::Depth			:
 	case Channel::Depth2		: return _processFloat(f,c, value("depth_min", f.getLeft().minDepth), value("depth_max", f.getLeft().maxDepth), stream);
 	case Channel::Normals		:
diff --git a/components/rgbd-sources/include/ftl/rgbd/frame.hpp b/components/rgbd-sources/include/ftl/rgbd/frame.hpp
index 6cbfcec5599b3eecee7a68e115d5874027eae13f..8fc747cd7f968bef73a09161619774bb9c607ed6 100644
--- a/components/rgbd-sources/include/ftl/rgbd/frame.hpp
+++ b/components/rgbd-sources/include/ftl/rgbd/frame.hpp
@@ -76,7 +76,11 @@ class Frame : public ftl::data::Frame<0,32,ftl::rgbd::FrameState,VideoData> {
 public:
 	using ftl::data::Frame<0,32,ftl::rgbd::FrameState,VideoData>::create;
 
-	Frame() {}
+	Frame();
+	Frame(Frame &&f);
+	~Frame();
+
+	Frame &operator=(Frame &&f);
 
 	// Prevent frame copy, instead use a move.
 	//Frame(const Frame &)=delete;
diff --git a/components/rgbd-sources/src/frame.cpp b/components/rgbd-sources/src/frame.cpp
index c5200dbca2e3988400155a6708dfda0c449b106f..8c809c026650015ca28b3a2074faaa9ab4fffb29 100644
--- a/components/rgbd-sources/src/frame.cpp
+++ b/components/rgbd-sources/src/frame.cpp
@@ -1,6 +1,9 @@
 
 #include <ftl/rgbd/frame.hpp>
 
+#define LOGURU_REPLACE_GLOG 1
+#include <loguru.hpp>
+
 using ftl::rgbd::Frame;
 using ftl::rgbd::FrameState;
 using ftl::codecs::Channels;
@@ -9,6 +12,7 @@ using ftl::rgbd::VideoData;
 
 static cv::Mat none;
 static cv::cuda::GpuMat noneGPU;
+static std::atomic<int> frame_count = 0;
 
 template <>
 cv::Mat &VideoData::as<cv::Mat>() {
@@ -72,6 +76,24 @@ cv::cuda::GpuMat &VideoData::make<cv::cuda::GpuMat>() {
 	}
 }*/
 
+Frame::Frame() {
+	++frame_count;
+	//LOG(INFO) << "Frames: " << frame_count;
+}
+
+Frame::Frame(Frame &&f) : ftl::data::Frame<0,32,ftl::rgbd::FrameState,VideoData>(std::move(f)) {
+
+}
+
+Frame &Frame::operator=(Frame &&f) {
+	ftl::data::Frame<0,32,ftl::rgbd::FrameState,VideoData>::operator=(std::move(f));
+	return *this;
+}
+
+Frame::~Frame() {
+	--frame_count;
+}
+
 void Frame::download(Channel c, cv::cuda::Stream stream) {
 	download(Channels(c), stream);
 }
diff --git a/components/streams/include/ftl/streams/filestream.hpp b/components/streams/include/ftl/streams/filestream.hpp
index a5d75b9477d07ad464ac28dc007100a34c337bdc..bd6a580ea03d7358b8254ae5f4ad7cce76814466 100644
--- a/components/streams/include/ftl/streams/filestream.hpp
+++ b/components/streams/include/ftl/streams/filestream.hpp
@@ -70,6 +70,7 @@ class File : public Stream {
 	bool active_;
 	int version_;
 	ftl::timer::TimerHandle timer_;
+	bool is_video_;
 
 	StreamCallback cb_;
 	MUTEX mutex_;
diff --git a/components/streams/src/injectors.hpp b/components/streams/include/ftl/streams/injectors.hpp
similarity index 100%
rename from components/streams/src/injectors.hpp
rename to components/streams/include/ftl/streams/injectors.hpp
diff --git a/components/streams/src/parsers.hpp b/components/streams/include/ftl/streams/parsers.hpp
similarity index 100%
rename from components/streams/src/parsers.hpp
rename to components/streams/include/ftl/streams/parsers.hpp
diff --git a/components/streams/src/filestream.cpp b/components/streams/src/filestream.cpp
index 189a970f4674f051a2fec32d10923c1822855211..75de7bdc3f2ddd79476acff00c97c0d071cfa9b0 100644
--- a/components/streams/src/filestream.cpp
+++ b/components/streams/src/filestream.cpp
@@ -71,7 +71,10 @@ bool File::_checkFile() {
 
 	checked_ = true;
 
+	is_video_ = count < 9;
+
 	LOG(INFO) << " -- Frame rate = " << (1000 / min_ts_diff);
+	if (!is_video_) LOG(INFO) << " -- Static image";
 	interval_ = min_ts_diff;
 	return true;
 }
@@ -222,6 +225,7 @@ bool File::tick(int64_t ts) {
 		// Adjust timestamp
 		// FIXME: A potential bug where multiple times are merged into one?
 		std::get<0>(data).timestamp = (((std::get<0>(data).timestamp) - first_ts_) / interval_) * interval_ + timestart_;
+		std::get<0>(data).hint_capability = (is_video_) ? 0 : ftl::codecs::kStreamCap_Static;
 
 		// Maintain availability of channels.
 		available(0) += std::get<0>(data).channel;
diff --git a/components/streams/src/injectors.cpp b/components/streams/src/injectors.cpp
index 2a58e5eac0fe174233b39a4a7ba501f3a54e3795..01dcbef368a8b642abbdf91b25aa31e3c8ee857c 100644
--- a/components/streams/src/injectors.cpp
+++ b/components/streams/src/injectors.cpp
@@ -1,4 +1,4 @@
-#include "injectors.hpp"
+#include <ftl/streams/injectors.hpp>
 #include <ftl/utility/vectorbuffer.hpp>
 
 using ftl::codecs::Channel;
diff --git a/components/streams/src/netstream.cpp b/components/streams/src/netstream.cpp
index a7cef8a2d5c225cdeece3e3971f55aafaeb15a28..0cba3f4dc533a3cfd71b116e0da9709718fbe3da 100644
--- a/components/streams/src/netstream.cpp
+++ b/components/streams/src/netstream.cpp
@@ -154,6 +154,8 @@ bool Net::begin() {
 		// FIXME: see #335
 		//spkt.timestamp -= clock_adjust_;
 		spkt.originClockDelta = clock_adjust_;
+		spkt.hint_capability = 0;
+		spkt.hint_source_total = 0;
 		//LOG(INFO) << "LATENCY: " << ftl::timer::get_time() - spkt.localTimestamp() << " : " << spkt.timestamp << " - " << clock_adjust_;
 		spkt.version = 4;
 
@@ -275,7 +277,10 @@ bool Net::_sendRequest(Channel c, uint8_t frameset, uint8_t frames, uint8_t coun
 		ftl::timer::get_time(),
 		frameset,
 		frames,
-		c
+		c,
+		0,
+		0,
+		0
 	};
 
 	net_->send(peer_, uri_, (short)0, spkt, pkt);
diff --git a/components/streams/src/parsers.cpp b/components/streams/src/parsers.cpp
index 95c521da670eaead6ac6723e4636e9b1440d8b2e..29a3350ec1550f26a38856cfe3137758998c1032 100644
--- a/components/streams/src/parsers.cpp
+++ b/components/streams/src/parsers.cpp
@@ -1,4 +1,4 @@
-#include "parsers.hpp"
+#include <ftl/streams/parsers.hpp>
 
 #include <loguru.hpp>
 
diff --git a/components/streams/src/receiver.cpp b/components/streams/src/receiver.cpp
index d34c256bf8b9287ed4ec94947274c25df4687f21..ac61f171786ded4a1e2f2c54a4c2c3dacd881e2a 100644
--- a/components/streams/src/receiver.cpp
+++ b/components/streams/src/receiver.cpp
@@ -4,8 +4,8 @@
 
 #include <opencv2/cudaimgproc.hpp>
 
-#include "parsers.hpp"
-#include "injectors.hpp"
+#include <ftl/streams/parsers.hpp>
+#include <ftl/streams/injectors.hpp>
 
 #define LOGURU_REPLACE_GLOG 1
 #include <loguru.hpp>
@@ -205,6 +205,8 @@ void Receiver::_processVideo(const StreamPacket &spkt, const Packet &pkt) {
 	// Allocate a decode surface, this is a tiled image to be split later
 	surface.create(height*ty, width*tx, ((isFloatChannel(spkt.channel)) ? ((pkt.flags & 0x2) ? CV_16UC4 : CV_16U) : CV_8UC4));
 
+	bool is_static = ividstate.decoders[channum] && (spkt.hint_capability & ftl::codecs::kStreamCap_Static);
+
 	// Find or create the decoder
 	_createDecoder(ividstate, channum, pkt);
 	auto *decoder = ividstate.decoders[channum];
@@ -214,15 +216,17 @@ void Receiver::_processVideo(const StreamPacket &spkt, const Packet &pkt) {
 	}
 
 	// Do the actual decode into the surface buffer
-	try {
-		FTL_Profile("Decode", 0.015);
-		if (!decoder->decode(pkt, surface)) {
-			LOG(ERROR) << "Decode failed on channel " << (int)spkt.channel;
+	if (!is_static) {
+		try {
+			FTL_Profile("Decode", 0.015);
+			if (!decoder->decode(pkt, surface)) {
+				LOG(ERROR) << "Decode failed on channel " << (int)spkt.channel;
+				return;
+			}
+		} catch (std::exception &e) {
+			LOG(ERROR) << "Decode failed for " << spkt.timestamp << ": " << e.what();
 			return;
 		}
-	} catch (std::exception &e) {
-		LOG(ERROR) << "Decode failed for " << spkt.timestamp << ": " << e.what();
-		return;
 	}
 
 	auto cvstream = cv::cuda::StreamAccessor::wrapStream(decoder->stream());
diff --git a/components/streams/src/sender.cpp b/components/streams/src/sender.cpp
index b85625fa0e56acc107ef840dc6d36792989792a0..18717bb4becda1344c265f7df5bb454c5edcf233 100644
--- a/components/streams/src/sender.cpp
+++ b/components/streams/src/sender.cpp
@@ -4,7 +4,7 @@
 
 #include <opencv2/cudaimgproc.hpp>
 
-#include "injectors.hpp"
+#include <ftl/streams/injectors.hpp>
 
 #define LOGURU_REPLACE_GLOG 1
 #include <loguru.hpp>
diff --git a/components/streams/test/receiver_unit.cpp b/components/streams/test/receiver_unit.cpp
index 2aadaa8e4cc4d51ba2598c75a7f8752d8a9aea91..755c55c2293717c3490171813f08abebec735343 100644
--- a/components/streams/test/receiver_unit.cpp
+++ b/components/streams/test/receiver_unit.cpp
@@ -2,7 +2,7 @@
 
 #include <ftl/streams/receiver.hpp>
 #include <ftl/codecs/nvpipe_encoder.hpp>
-#include "../src/injectors.hpp"
+#include <ftl/streams/injectors.hpp>
 
 #include <nlohmann/json.hpp>
 
diff --git a/components/structures/include/ftl/data/frame.hpp b/components/structures/include/ftl/data/frame.hpp
index 88f31c4b8dcd22f57ea953e9978fdc5679e57bd5..637621169847822ae835abdf0049dfee8a9d1b07 100644
--- a/components/structures/include/ftl/data/frame.hpp
+++ b/components/structures/include/ftl/data/frame.hpp
@@ -54,10 +54,20 @@ class Frame {
 
 public:
 	Frame() : origin_(nullptr) {}
+	Frame(Frame &&f) {
+		f.swapTo(*this);
+		f.reset();
+	}
+
+	Frame &operator=(Frame &&f) {
+		f.swapTo(*this);
+		f.reset();
+		return *this;
+	}
 
 	// Prevent frame copy, instead use a move.
-	//Frame(const Frame &)=delete;
-	//Frame &operator=(const Frame &)=delete;
+	Frame(const Frame &)=delete;
+	Frame &operator=(const Frame &)=delete;
 
 	/**
 	 * Perform a buffer swap of the selected channels. This is intended to be