DEV Community

vmodal_ai
vmodal_ai

Posted on

Bringing Unitree Go1 Camera Data into a Kotlin Android App (via a Bridge Server)

The UnitreeCameraSDK is a C++ library that runs on the Jetson boards inside a Unitree Go1 — it has no Android build target, no JNI bindings, and no Kotlin/Flutter equivalent. You can't implementation it into a Gradle project.

What you can do is put a small bridge server next to the SDK, on the same Linux/Jetson environment it already runs on, and have that server expose camera frames and depth data as plain HTTP/WebSocket traffic. Your Kotlin app then just talks to that server like it would talk to any other backend — no C++, no NDK.

This tutorial builds that bridge end to end: a C++ server wrapping the SDK, and a Kotlin Android app that consumes it.

Architecture

Two independent programs, talking over your local network:

  • On the robot (Jetson): the existing UnitreeCameraSDK capturing frames, wrapped by a small bridge server that serves an MJPEG video stream over HTTP and depth/point-cloud data over WebSocket as JSON.
  • On the phone: a Kotlin app with an OkHttp-based MJPEG client for video, and an OkHttp WebSocket client for depth data.

Nothing about the SDK itself changes — it keeps running exactly as documented. The bridge is just a thin translation layer sitting in front of it.

Part 1 — The bridge server (C++, runs on the Jetson)

Why a custom server instead of the SDK's own network example

The SDK does ship a example_putImagetrans/example_getimagetrans pair for sending frames over the network, but it uses Jetson-specific GStreamer elements (omxh264dec) that generally don't exist off-device, and it doesn't expose depth or calibration data at all. For a mobile client we want something protocol-simple (HTTP/WebSocket) and complete (video and depth), so we write a small server ourselves instead of reusing that example directly.

Dependencies

  • The UnitreeCameraSDK itself (built as in the original SDK README)
  • OpenCV (already required by the SDK)
  • civetweb — a small embeddable HTTP/WebSocket server, single dependency, easy to vendor into a CMake project
  • nlohmann/json — header-only JSON, for encoding depth/calibration data

Step 1 — Capture frames from the SDK

This part uses the SDK exactly as documented — the same call shape you'd use in example_getRawFrame:

#include "UnitreeCameraSDK.hpp"
#include <opencv2/opencv.hpp>

UnitreeCamera cam(std::string("/dev/videoX")); // dev node for the camera pair you want

if (!cam.isOpened()) {
    std::cerr << "Failed to open camera" << std::endl;
    return -1;
}
cam.startCapture();
Enter fullscreen mode Exit fullscreen mode

Method names and the constructor signature can vary slightly by SDK checkout — cross-check against include/UnitreeCameraSDK.hpp and the matching example .cc file in your version before wiring this up.

Step 2 — Serve an MJPEG stream over HTTP

MJPEG (a sequence of JPEG frames sent as a multipart/x-mixed-replace HTTP response) is the easiest possible video protocol to consume on Android — no codec negotiation, no WebRTC signaling, just a long-lived HTTP GET.

#include "civetweb.h"
#include <opencv2/opencv.hpp>
#include <mutex>

std::mutex frameMutex;
cv::Mat latestFrame;

int mjpegHandler(struct mg_connection *conn, void *) {
    mg_printf(conn,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n");

    while (true) {
        cv::Mat frame;
        {
            std::lock_guard<std::mutex> lock(frameMutex);
            if (latestFrame.empty()) continue;
            frame = latestFrame.clone();
        }

        std::vector<uchar> jpegBuf;
        cv::imencode(".jpg", frame, jpegBuf);

        mg_printf(conn,
            "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %zu\r\n\r\n",
            jpegBuf.size());
        mg_write(conn, jpegBuf.data(), jpegBuf.size());
        mg_printf(conn, "\r\n");

        std::this_thread::sleep_for(std::chrono::milliseconds(33)); // ~30fps
    }
    return 200;
}
Enter fullscreen mode Exit fullscreen mode

The camera capture loop (running on its own thread) just writes into latestFrame on every new frame from cam.getRawFrame(...), guarded by the same mutex.

Step 3 — Serve depth data and calibration over WebSocket as JSON

Depth frames and calibration parameters don't need to stream at video framerate, so a WebSocket that pushes a JSON message per request (or on a slower interval) works well:

#include "civetweb.h"
#include <nlohmann/json.hpp>

int depthWsData(struct mg_connection *conn, int bits, char *data, size_t len, void *) {
    return 1; // ignore incoming client messages for now
}

int depthWsReady(const struct mg_connection *conn, void *) {
    cv::Mat depth;
    {
        std::lock_guard<std::mutex> lock(depthMutex);
        depth = latestDepth.clone();
    }

    // Downsample + encode depth as base64 PNG to keep payload manageable
    std::vector<uchar> pngBuf;
    cv::imencode(".png", depth, pngBuf);
    std::string b64 = base64Encode(pngBuf.data(), pngBuf.size());

    nlohmann::json msg = {
        {"type", "depth_frame"},
        {"width", depth.cols},
        {"height", depth.rows},
        {"encoding", "png_base64"},
        {"data", b64}
    };

    std::string out = msg.dump();
    mg_websocket_write((struct mg_connection*)conn, MG_WEBSOCKET_OPCODE_TEXT,
                        out.c_str(), out.size());
    return 1;
}
Enter fullscreen mode Exit fullscreen mode

Calibration parameters (intrinsics, distortion coefficients, rectification rotation, baseline) are static per device, so expose them as a plain REST endpoint instead of a WebSocket push:

int calibHandler(struct mg_connection *conn, void *) {
    nlohmann::json calib = {
        {"left_intrinsic", leftIntrinsicMatrixAsJsonArray()},
        {"right_intrinsic", rightIntrinsicMatrixAsJsonArray()},
        {"baseline", stereoBaseline}
    };
    std::string body = calib.dump();
    mg_printf(conn,
        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %zu\r\n\r\n%s",
        body.size(), body.c_str());
    return 200;
}
Enter fullscreen mode Exit fullscreen mode

Step 4 — Wire up routes and start the server

int main() {
    struct mg_callbacks callbacks = {};
    const char *options[] = {
        "listening_ports", "8080",
        "num_threads", "4",
        nullptr
    };
    struct mg_context *ctx = mg_start(&callbacks, nullptr, options);

    mg_set_request_handler(ctx, "/stream/raw", mjpegHandler, nullptr);
    mg_set_request_handler(ctx, "/api/calibration", calibHandler, nullptr);
    mg_set_websocket_handler(ctx, "/ws/depth", nullptr, depthWsReady, depthWsData, nullptr, nullptr);

    // start camera capture thread here (calls cam.getRawFrame in a loop,
    // writes into latestFrame / latestDepth under the mutexes above)

    std::cout << "Bridge server running on :8080" << std::endl;
    while (true) std::this_thread::sleep_for(std::chrono::seconds(1));
}
Enter fullscreen mode Exit fullscreen mode

Add civetweb and nlohmann_json to your CMakeLists.txt alongside the existing SDK/OpenCV dependencies, build, and run this alongside (not instead of) your normal SDK setup. Once it's running, http://<jetson-ip>:8080/stream/raw gives you an MJPEG feed you can sanity-check directly in a desktop browser before touching Android at all.

Part 2 — The Kotlin Android app

Step 1 — Permissions and dependencies

AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />
Enter fullscreen mode Exit fullscreen mode

app/build.gradle.kts:

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
}
Enter fullscreen mode Exit fullscreen mode

OkHttp handles both the long-lived MJPEG HTTP connection and the WebSocket — you don't need a separate video library for this.

Step 2 — An MJPEG client

MJPEG isn't natively supported by Android's video views, but parsing it is simple: read the response body as a stream, split on the --frame boundary, decode each JPEG chunk as a Bitmap.

class MjpegClient(private val url: String) {
    private val client = OkHttpClient.Builder()
        .readTimeout(0, TimeUnit.MILLISECONDS) // stream never "completes"
        .build()

    fun start(onFrame: (Bitmap) -> Unit) {
        val request = Request.Builder().url(url).build()
        client.newCall(request).execute().use { response ->
            val input = response.body?.byteStream() ?: return
            val boundary = "--frame".toByteArray()
            val buffer = ByteArrayOutputStream()
            var lastByte = -1

            while (true) {
                val current = input.read()
                if (current == -1) break
                buffer.write(current)

                // crude JPEG end-of-image marker (0xFFD9) detection
                if (lastByte == 0xFF && current == 0xD9) {
                    val jpegBytes = buffer.toByteArray()
                    val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
                    if (bitmap != null) onFrame(bitmap)
                    buffer.reset()
                }
                lastByte = current
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Using it from an Activity/Fragment with a coroutine so it doesn't block the UI thread:

class CameraViewModel : ViewModel() {
    private val client = MjpegClient("http://192.168.123.15:8080/stream/raw")

    val currentFrame = MutableLiveData<Bitmap>()

    fun startStreaming() {
        viewModelScope.launch(Dispatchers.IO) {
            client.start { bitmap ->
                currentFrame.postValue(bitmap)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And bind it to an ImageView in your composable or XML layout:

@Composable
fun CameraStreamView(viewModel: CameraViewModel) {
    val frame by viewModel.currentFrame.observeAsState()
    LaunchedEffect(Unit) { viewModel.startStreaming() }

    frame?.let { bitmap ->
        Image(bitmap = bitmap.asImageBitmap(), contentDescription = "Robot camera feed")
    }
}
Enter fullscreen mode Exit fullscreen mode

Swap in whichever image-loading approach fits your app (Jetpack Compose Image, or a plain ImageView.setImageBitmap in XML-based views) — the streaming logic above is UI-framework agnostic.

Step 3 — A WebSocket client for depth data

class DepthClient(private val url: String) {
    private val client = OkHttpClient()
    private var socket: WebSocket? = null

    fun connect(onDepthFrame: (width: Int, height: Int, bitmap: Bitmap) -> Unit) {
        val request = Request.Builder().url(url).build()
        socket = client.newWebSocket(request, object : WebSocketListener() {
            override fun onMessage(webSocket: WebSocket, text: String) {
                val json = JSONObject(text)
                if (json.getString("type") != "depth_frame") return

                val width = json.getInt("width")
                val height = json.getInt("height")
                val b64 = json.getString("data")
                val bytes = Base64.decode(b64, Base64.DEFAULT)
                val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)

                onDepthFrame(width, height, bitmap)
            }
        })
    }

    fun disconnect() {
        socket?.close(1000, "done")
    }
}
Enter fullscreen mode Exit fullscreen mode

Connect it the same way as the MJPEG client — from a ViewModel, posting results to LiveData or a StateFlow, and collect it in your composable/UI layer.

Step 4 — Calibration data (plain REST call)

Since calibration is static per device, a one-shot REST call with a JSON parsing library is enough — no need for a persistent connection:

suspend fun fetchCalibration(): CalibrationData = withContext(Dispatchers.IO) {
    val client = OkHttpClient()
    val request = Request.Builder().url("http://192.168.123.15:8080/api/calibration").build()
    val response = client.newCall(request).execute()
    val json = JSONObject(response.body!!.string())
    CalibrationData(
        leftIntrinsic = json.getJSONArray("left_intrinsic"),
        rightIntrinsic = json.getJSONArray("right_intrinsic"),
        baseline = json.getDouble("baseline")
    )
}
Enter fullscreen mode Exit fullscreen mode

Testing the whole pipeline

  1. Build and run the bridge server on the Jetson: confirm http://<jetson-ip>:8080/stream/raw shows a live MJPEG feed in a desktop browser first — this isolates SDK/server issues from anything Android-specific.
  2. Make sure your phone is on the same network as the robot (the Go1's onboard boards sit at 192.168.123.13/14/15 by default).
  3. Run the Android app pointed at the Jetson's IP and confirm frames start appearing.
  4. Bring up the WebSocket depth connection and check for depth_frame messages in Logcat before wiring it to UI.

Things to watch for

  • Same device-locking issue as the raw SDK. Unitree's own background services (point_cloud_node, mqttControlNode, live_human_pose) can hold the camera open before your bridge server even starts. Kill them first, same as with the plain SDK examples.
  • OpenCV version. The SDK is happiest on OpenCV 4.1.1 — pin it on the Jetson side; it has no bearing on the Android side since the phone never touches OpenCV directly.
  • MJPEG frame parsing is naive here. The boundary-detection logic above is a minimal implementation for clarity — for production use, parse the actual Content-Length header per part instead of scanning for JPEG end markers, which is more robust against corrupted frames.
  • Bandwidth. Full-resolution MJPEG at 30fps over Wi-Fi can be heavy — consider having the bridge server downscale or reduce framerate for the mobile stream specifically, independent of whatever resolution you use for on-robot processing.
  • This bridge is unauthenticated as written. Fine on a private robot network; add a token check in the HTTP/WebSocket handlers before exposing it on anything less trusted.

Wrapping up

The SDK itself never has to change or move — it keeps running on the Jetson exactly as Unitree shipped it. The bridge server is the only new C++ code, and it's small: an MJPEG endpoint, a WebSocket endpoint, and a REST endpoint for calibration. Everything on the Kotlin side is just OkHttp doing what OkHttp always does — no native bindings, no NDK, no cross-compilation. If you outgrow MJPEG later (for lower latency or better compression), the same server structure supports swapping in WebRTC or RTSP without touching how your Android app is architected.

If you build this against your own Go1, I'd be curious what framerate/latency you're seeing over Wi-Fi — drop it in the comments.

Useful Links

Website: www.v-modal.com

SDK Flutter: https://github.com/v-modal/vmodal_sdk_flutter

SDK Android: https://github.com/v-modal/vmodal_sdk_android

Discord: https://discord.gg/K72z28KUx

Top comments (0)