diff --git a/SDK/C/CMakeLists.txt b/SDK/C/CMakeLists.txt
index e34a99214a897f32ef913c48750bad392e5400cc..e4f0fadcbc3569d31ec726215aea2370b6e9410f 100644
--- a/SDK/C/CMakeLists.txt
+++ b/SDK/C/CMakeLists.txt
@@ -7,12 +7,17 @@ add_library(ftl-dev SHARED ${SDKSRC})
 set_target_properties(ftl-dev PROPERTIES VERSION ${PROJECT_VERSION})
 set_target_properties(ftl-dev PROPERTIES SOVERSION 0)
 
+
 target_include_directories(ftl-dev PUBLIC
 	$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
 	$<INSTALL_INTERFACE:include>
 	PRIVATE src)
 
-target_link_libraries(ftl-dev ftlcommon ftlrgbd ftlstreams Threads::Threads ${OpenCV_LIBS} ftlnet)
+if (CUDA_FOUND)
+set_property(TARGET ftl-dev PROPERTY CUDA_SEPARABLE_COMPILATION OFF)
+endif()
+
+target_link_libraries(ftl-dev ftlcommon ftlcodecs ftlrgbd ftlstreams Threads::Threads ${OpenCV_LIBS} ftlnet)
 
 install(TARGETS ftl-dev
     LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
diff --git a/SDK/C/src/streams.cpp b/SDK/C/src/streams.cpp
index ea112ad5caed1cdd75ab67277197a9b2ef65562c..abf8a5f990ef58a1488a3ac12789ad1df57a319b 100644
--- a/SDK/C/src/streams.cpp
+++ b/SDK/C/src/streams.cpp
@@ -5,6 +5,8 @@
 #include <ftl/streams/filestream.hpp>
 #include <ftl/streams/netstream.hpp>
 
+#include <opencv2/imgproc.hpp>
+
 #define LOGURU_REPLACE_GLOG 1
 #include <loguru.hpp>
 
@@ -69,6 +71,7 @@ ftlStream_t ftlCreateWriteStream(const char *uri) {
 
 	if (s->last_error == FTLERROR_OK) {
 		s->sender = ftl::create<ftl::stream::Sender>(root, "sender");
+		//s->sender->set("codec", 0);
 		s->sender->setStream(s->stream);
 		if (!s->stream->begin()) {
 			last_error = FTLERROR_STREAM_FILE_CREATE_FAILED;
@@ -102,6 +105,8 @@ ftlError_t ftlImageWrite(
 	if (stream->video_fs.hasChannel(static_cast<ftl::codecs::Channel>(channel)))
 		return FTLERROR_STREAM_DUPLICATE;
 
+	stream->sender->set("encoder_device", 2);  // Software encoder
+
 	try {
 		auto &frame = stream->video_fs.frames[sourceId];
 		auto &img = frame.create<cv::cuda::GpuMat>(static_cast<ftl::codecs::Channel>(channel));
@@ -111,16 +116,25 @@ ftlError_t ftlImageWrite(
 			return FTLERROR_STREAM_NO_INTRINSICS;
 		}
 
+		cv::Mat tmp,tmp2;
+
 		switch (type) {
-		case FTLIMAGE_FLOAT		: img.upload(cv::Mat(intrin.height, intrin.width, CV_32F, const_cast<void*>(data), pitch)); break;
-		case FTLIMAGE_BGRA		: img.upload(cv::Mat(intrin.height, intrin.width, CV_8UC4, const_cast<void*>(data), pitch)); break;
-		case FTLIMAGE_RGBA		:
-		case FTLIMAGE_BGR		:
-		case FTLIMAGE_RGB		:
+		case FTLIMAGE_FLOAT		:	tmp2 = cv::Mat(intrin.height, intrin.width, CV_32F, const_cast<void*>(data), pitch); break;
+		case FTLIMAGE_BGRA		:	tmp2 = cv::Mat(intrin.height, intrin.width, CV_8UC4, const_cast<void*>(data), pitch); break;
+		case FTLIMAGE_RGBA		:	tmp = cv::Mat(intrin.height, intrin.width, CV_8UC4, const_cast<void*>(data), pitch);
+									cv::cvtColor(tmp, tmp2, cv::COLOR_RGBA2BGRA);
+									break;
+		case FTLIMAGE_BGR		:	tmp = cv::Mat(intrin.height, intrin.width, CV_8UC3, const_cast<void*>(data), pitch);
+									cv::cvtColor(tmp, tmp2, cv::COLOR_BGR2BGRA);
+									break;
+		case FTLIMAGE_RGB		:	tmp = cv::Mat(intrin.height, intrin.width, CV_8UC3, const_cast<void*>(data), pitch);
+									cv::cvtColor(tmp, tmp2, cv::COLOR_RGB2BGRA);
+									break;
 		default					: return FTLERROR_STREAM_BAD_IMAGE_TYPE;
 		}
 
-		if (img.empty()) return FTLERROR_STREAM_NO_DATA;
+		if (tmp2.empty()) return FTLERROR_STREAM_NO_DATA;
+		img.upload(tmp2);
 
 		ftl::codecs::Channels<0> channels;
 		if (stream->stream->size() > static_cast<unsigned int>(stream->video_fs.id)) channels = stream->stream->selected(stream->video_fs.id);
@@ -229,6 +243,7 @@ ftlError_t ftlNextFrame(ftlStream_t stream) {
 	if (!stream->has_fresh_data) return FTLERROR_STREAM_NO_DATA;
 
 	try {
+		cudaSetDevice(0);
 		stream->sender->post(stream->video_fs);
 	} catch (const std::exception &e) {
 		return FTLERROR_STREAM_ENCODE_FAILED;
@@ -255,23 +270,26 @@ ftlError_t ftlDestroyStream(ftlStream_t stream) {
 	if (!stream) return FTLERROR_STREAM_INVALID_STREAM;
 	if (!stream->stream) return FTLERROR_STREAM_INVALID_STREAM;
 
-	ftlError_t err = FTLERROR_OK;
+	ftl::pool.push([stream](int id) {
+		//ftlError_t err = FTLERROR_OK;
 
-	if (stream->has_fresh_data) {
-		try {
-			stream->sender->post(stream->video_fs);
-		} catch (const std::exception &e) {
-			err = FTLERROR_STREAM_ENCODE_FAILED;
+		if (stream->has_fresh_data) {
+			try {
+				cudaSetDevice(0);
+				stream->sender->post(stream->video_fs);
+			} catch (const std::exception &e) {
+				//err = FTLERROR_STREAM_ENCODE_FAILED;
+			}
 		}
-	}
 
-	if (!stream->stream->end()) {
-		err = FTLERROR_STREAM_FILE_CREATE_FAILED;
-	}
-	if (stream->sender) delete stream->sender;
-	delete stream->stream;
-	stream->sender = nullptr;
-	stream->stream = nullptr;
-	delete stream;
-	return err;
+		if (!stream->stream->end()) {
+			//err = FTLERROR_STREAM_FILE_CREATE_FAILED;
+		}
+		if (stream->sender) delete stream->sender;
+		delete stream->stream;
+		stream->sender = nullptr;
+		stream->stream = nullptr;
+		delete stream;
+	});
+	return FTLERROR_OK;
 }
diff --git a/SDK/Python/blender_script.py b/SDK/Python/blender_script.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce562f8622ce9d5e3b9f85b86c7dc598a2b2500e
--- /dev/null
+++ b/SDK/Python/blender_script.py
@@ -0,0 +1,204 @@
+import bpy
+import numpy as np
+from mathutils import Matrix, Vector
+
+_d_max = 65504.0
+
+################################################################################
+# https://blender.stackexchange.com/a/120063
+
+# BKE_camera_sensor_size
+def get_sensor_size(sensor_fit, sensor_x, sensor_y):
+    if sensor_fit == 'VERTICAL':
+        return sensor_y
+    return sensor_x
+
+# BKE_camera_sensor_fit
+def get_sensor_fit(sensor_fit, size_x, size_y):
+    if sensor_fit == 'AUTO':
+        if size_x >= size_y:
+            return 'HORIZONTAL'
+        else:
+            return 'VERTICAL'
+    return sensor_fit
+
+# Build intrinsic camera parameters from Blender camera data
+#
+# See notes on this in 
+# blender.stackexchange.com/questions/15102/what-is-blenders-camera-projection-matrix-model
+# as well as
+# https://blender.stackexchange.com/a/120063/3581
+def get_calibration_matrix_K_from_blender(camd):
+    if camd.type != 'PERSP':
+        raise ValueError('Non-perspective cameras not supported')
+    scene = bpy.context.scene
+    f_in_mm = camd.lens
+    scale = scene.render.resolution_percentage / 100
+    resolution_x_in_px = scale * scene.render.resolution_x
+    resolution_y_in_px = scale * scene.render.resolution_y
+    sensor_size_in_mm = get_sensor_size(camd.sensor_fit, camd.sensor_width, camd.sensor_height)
+    sensor_fit = get_sensor_fit(
+        camd.sensor_fit,
+        scene.render.pixel_aspect_x * resolution_x_in_px,
+        scene.render.pixel_aspect_y * resolution_y_in_px
+    )
+    pixel_aspect_ratio = scene.render.pixel_aspect_y / scene.render.pixel_aspect_x
+    if sensor_fit == 'HORIZONTAL':
+        view_fac_in_px = resolution_x_in_px
+    else:
+        view_fac_in_px = pixel_aspect_ratio * resolution_y_in_px
+    pixel_size_mm_per_px = sensor_size_in_mm / f_in_mm / view_fac_in_px
+    s_u = 1 / pixel_size_mm_per_px
+    s_v = 1 / pixel_size_mm_per_px / pixel_aspect_ratio
+
+    # Parameters of intrinsic calibration matrix K
+    u_0 = resolution_x_in_px / 2 - camd.shift_x * view_fac_in_px
+    v_0 = resolution_y_in_px / 2 + camd.shift_y * view_fac_in_px / pixel_aspect_ratio
+    skew = 0 # only use rectangular pixels
+
+    K = Matrix(
+        ((s_u, skew, u_0),
+        (   0,  s_v, v_0),
+        (   0,    0,   1)))
+    return K
+
+# Returns camera rotation and translation matrices from Blender.
+# 
+# There are 3 coordinate systems involved:
+#    1. The World coordinates: "world"
+#       - right-handed
+#    2. The Blender camera coordinates: "bcam"
+#       - x is horizontal
+#       - y is up
+#       - right-handed: negative z look-at direction
+#    3. The desired computer vision camera coordinates: "cv"
+#       - x is horizontal
+#       - y is down (to align to the actual pixel coordinates 
+#         used in digital images)
+#       - right-handed: positive z look-at direction
+def get_3x4_RT_matrix_from_blender(cam):
+    # bcam stands for blender camera
+    R_bcam2cv = Matrix(
+        ((1, 0,  0),
+        (0, -1, 0),
+        (0, 0, -1)))
+
+    # Transpose since the rotation is object rotation, 
+    # and we want coordinate rotation
+    # R_world2bcam = cam.rotation_euler.to_matrix().transposed()
+    # T_world2bcam = -1*R_world2bcam * location
+    #
+    # Use matrix_world instead to account for all constraints
+    location, rotation = cam.matrix_world.decompose()[0:2]
+    R_world2bcam = rotation.to_matrix().transposed()
+
+    # Convert camera location to translation vector used in coordinate changes
+    # T_world2bcam = -1*R_world2bcam*cam.location
+    # Use location from matrix_world to account for constraints:     
+    T_world2bcam = -1*R_world2bcam @ location
+
+    # Build the coordinate transform matrix from world to computer vision camera
+    R_world2cv = R_bcam2cv@R_world2bcam
+    T_world2cv = R_bcam2cv@T_world2bcam
+
+    # put into 3x4 matrix
+    RT = Matrix((
+        R_world2cv[0][:] + (T_world2cv[0],),
+        R_world2cv[1][:] + (T_world2cv[1],),
+        R_world2cv[2][:] + (T_world2cv[2],)
+        ))
+    return RT
+
+def get_3x4_P_matrix_from_blender(cam):
+    K = get_calibration_matrix_K_from_blender(cam.data)
+    RT = get_3x4_RT_matrix_from_blender(cam)
+    return K@RT, K, RT
+
+################################################################################
+
+import typing
+
+class StereoImage(typing.NamedTuple):
+    K: np.array
+    pose: np.array
+    baseline: float
+    imL: np.array
+    depthL: np.array
+    imR: np.array
+    depthR: np.array
+
+def render():
+    """ render active camera (image and depth) """
+    
+    bpy.context.scene.use_nodes = True
+    tree = bpy.context.scene.node_tree
+    links = tree.links
+
+    for n in tree.nodes:
+        tree.nodes.remove(n)
+
+    rl = tree.nodes.new('CompositorNodeRLayers')
+
+    v = tree.nodes.new('CompositorNodeViewer')
+    v.use_alpha = True
+
+    links.new(rl.outputs['Image'], v.inputs['Image'])
+    # depth cannot be accessed in python; hack uses alpha to store z-values
+    links.new(rl.outputs['Depth'], v.inputs['Alpha'])
+
+    bpy.ops.render.render()
+    pixels = bpy.data.images['Viewer Node']
+    im = np.array(pixels.pixels[:]).reshape((pixels.size[1], pixels.size[0], pixels.channels))
+    
+    # set invalid depth values to 0.0
+    im[:,:,3][im[:,:,3] >= _d_max] = 0.0
+    return (im[:,:,0:2] * 255.0).astype(np.uint8), im[:,:,3]
+
+def render_stereo(camera, baseline=0.15):
+    bpy.context.scene.camera = camera
+    _, K, pose = get_3x4_P_matrix_from_blender(camera)
+    imL, depthL = render()
+    
+    location_old = camera.location.copy()
+    camera.location = (camera.matrix_world @ Vector((baseline, 0.0, 0.0, 1.0)))[0:3]
+    
+    imR, depthR = render()
+    
+    camera_location = location_old
+    
+    return StereoImage(np.array(K), pose, baseline, imL, depthL, imR, depthR)
+
+
+from ctypes import *
+ftl = CDLL('/home/nick/git-repos/ftl/build/SDK/C/libftl-dev.so.0')
+
+ftlCreateWriteStream = ftl.ftlCreateWriteStream
+ftlCreateWriteStream.restype = c_void_p
+ftlCreateWriteStream.argtypes = [c_char_p]
+
+ftlIntrinsicsWriteLeft = ftl.ftlIntrinsicsWriteLeft
+ftlIntrinsicsWriteLeft.restype = c_int
+ftlIntrinsicsWriteLeft.argtypes = [c_void_p, c_int, c_int, c_int, c_float, c_float, c_float, c_float, c_float, c_float]
+
+ftlImageWrite = ftl.ftlImageWrite
+ftlImageWrite.restype = c_int
+ftlImageWrite.argtypes = [c_void_p, c_int, c_int, c_int, c_int, c_void_p]
+
+ftlDestroyStream = ftl.ftlDestroyStream
+ftlDestroyStream.restype = c_int
+ftlDestroyStream.argtypes = [c_void_p]
+
+stream = ftlCreateWriteStream(b'./blender.ftl')
+
+image = render_stereo(bpy.context.scene.camera, 0.15)
+scale = bpy.context.scene.render.resolution_percentage / 100
+resolution_x_in_px = scale * bpy.context.scene.render.resolution_x
+resolution_y_in_px = scale * bpy.context.scene.render.resolution_y
+
+err = ftlIntrinsicsWriteLeft(c_void_p(stream), c_int(0), c_int(int(resolution_x_in_px)), c_int(int(resolution_y_in_px)), c_float(300.0), c_float(-resolution_x_in_px/2), c_float(-resolution_y_in_px/2), c_float(0.1), c_float(0.1), c_float(8.0))
+print(err)
+err = ftlImageWrite(stream, 0, 0, 3, 0, image.imL.ctypes.data_as(c_void_p))
+print(err)
+err = ftlDestroyStream(stream)
+print(err)
+
diff --git a/components/streams/src/sender.cpp b/components/streams/src/sender.cpp
index 18717bb4becda1344c265f7df5bb454c5edcf233..623baf76de7f541d487614467851ac82327db2bb 100644
--- a/components/streams/src/sender.cpp
+++ b/components/streams/src/sender.cpp
@@ -260,6 +260,7 @@ void Sender::_encodeChannel(ftl::rgbd::FrameSet &fs, Channel c, bool reset) {
 	int max_bitrate = std::max(0, std::min(255, value("max_bitrate", 255)));
 	//int min_bitrate = std::max(0, std::min(255, value("min_bitrate", 0)));  // TODO: Use this
 	codec_t codec = static_cast<codec_t>(value("codec", static_cast<int>(codec_t::Any)));
+	device_t device = static_cast<device_t>(value("encoder_device", static_cast<int>(device_t::Any)));
 
 	uint32_t offset = 0;
 	while (offset < fs.frames.size()) {
@@ -285,7 +286,7 @@ void Sender::_encodeChannel(ftl::rgbd::FrameSet &fs, Channel c, bool reset) {
 		ftl::codecs::Encoder *enc = tile.encoder[(offset==0)?0:1];
 		if (!enc) {
 			enc = ftl::codecs::allocateEncoder(
-				definition_t::HD1080, device_t::Any, codec);
+				definition_t::HD1080, device, codec);
 			tile.encoder[(offset==0)?0:1] = enc;
 		}