Rouvy Route Creator – Creating routes with a DJI Osmo Action 4 and GPS Remote

During the winter months I enjoy riding in my shed using Rouvy’s indoor cycling app, but I miss some of my regular routes during the summer days. So the prospect of being able to upload my own routes and footage got me very excited.

My excitement was short-lived after realising it only supported GoPro cameras with GPS built it. At this point I only had the DJI Action 4 which is a great bit of kit but it does not embed GPS into the video file like the GoPro. I went out and purchased the DJI Osmo Action GPS Bluetooth Remote Controller which does indeed and the GPS track to the video file. Now came the tricky part.

Fortunately, with a lot of persistence I successfully managed to upload a modified video to Rouvy Route Creator and it passed all the validation.

Below is a rough guide how I achieved this goal taken from very rough notes I made during the process. I will update this guide at a later date (probably the winter now) when i have actually got some routes in to Rouvy.

There are some caveats to this guide which are listed below:

  1. For this guide i used the DJI Osmo Action 4 with the GPS remote. I cannot confirm the same process for other cameras but the general principles should be similar.
  2. At the time i writing I never found time to actually record the routes i wanted to add and my Rouvy subscription has since expired. Therefore at this point in time i haven’t actually managed to get one of my routes on Rouvy other than get my test video to pass all the validation rules in Rouvy Route Creator.
  3. WARNING: The guide gets quite technical. Be sure to read through it first to determine if you feel comfortable with the process.

Step 1 – Extract the GPX from the DJI video
Use pyosmogps on the command line to extract the GPX from the DJI Video. Example:

python -m pyosmogps --frequency 18 --resampling-method lpf extract DJI_20251012143147_0001_D.mp4 DJI_20251012143147_0001_D.gpx

Step 2 – Convert the GPX to GPMF video output track
This is where it gets tricky (and a bit hazy). I cloned gpmf-write and created the gpx_to_gpmf code below that uses gpmf-write to generate a new mp4 file that contains the gps track in GoPro gpmf format.

I have added this code to a Github repository.

/*
 * GPX to GPMF Converter with Enhanced GPS Metadata
 * Converts GPX files directly to GoPro GPMF format in MP4
 * Includes GPS fix quality and precision data for compatibility with tools like Rouvy
 * 
 * Compile (from gpmf-write directory):
 * gcc -o gpx_to_gpmf gpx_to_gpmf.c \
 *     GPMF_writer.c demo/GPMF_mp4writer.c demo/GPMF_parser.c \
 *     demo/GPMF_print.c -I. -Idemo -lm -D_FILE_OFFSET_BITS=64
 * 
 * Usage: ./gpx_to_gpmf input.gpx output.mp4
 */

#define _XOPEN_SOURCE 700
#define _GNU_SOURCE

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#include <ctype.h>

#include "GPMF_common.h"
#include "GPMF_writer.h"
#include "GPMF_parser.h"
#include "GPMF_mp4writer.h"

#define MAX_LINE_LENGTH 1024
#define GPS_SAMPLES_PER_PAYLOAD 18

extern void PrintGPMF(GPMF_stream *);

typedef struct {
    uint64_t timestamp_us;  // microseconds since epoch
    int32_t latitude;       // scaled by 10000000
    int32_t longitude;      // scaled by 10000000
    int32_t altitude;       // scaled by 1000 (millimeters)
    int32_t speed2d;        // scaled by 1000
    int32_t speed3d;        // scaled by 1000
    uint16_t gps_fix;       // 0=no fix, 2=2D, 3=3D
    uint16_t gps_precision; // HDOP * 100
} GPSData;

// Simple XML tag extractor
char* extract_tag_content(const char *line, const char *tag) {
    static char content[256];
    char open_tag[64], close_tag[64];
    snprintf(open_tag, sizeof(open_tag), "<%s>", tag);
    snprintf(close_tag, sizeof(close_tag), "</%s>", tag);
    
    char *start = strstr(line, open_tag);
    if (!start) return NULL;
    start += strlen(open_tag);
    
    char *end = strstr(start, close_tag);
    if (!end) return NULL;
    
    int len = end - start;
    if (len >= sizeof(content)) len = sizeof(content) - 1;
    strncpy(content, start, len);
    content[len] = '\0';
    
    return content;
}

// Parse ISO 8601 timestamp to microseconds
uint64_t parse_timestamp(const char *ts_str) {
    struct tm tm = {0};
    char *ptr;
    int microseconds = 0;
    
    // Parse main timestamp: 2025-10-12T20:31:48
    ptr = strptime(ts_str, "%Y-%m-%dT%H:%M:%S", &tm);
    if (!ptr) return 0;
    
    // Parse fractional seconds if present
    if (*ptr == '.') {
        ptr++;
        char frac[7] = {0};
        int i = 0;
        while (*ptr >= '0' && *ptr <= '9' && i < 6) {
            frac[i++] = *ptr++;
        }
        while (i < 6) frac[i++] = '0';
        microseconds = atoi(frac);
    }
    
    tm.tm_isdst = -1; // Let mktime determine DST
    time_t seconds = mktime(&tm);
    return (uint64_t)seconds * 1000000 + microseconds;
}

// Read GPS data from GPX file
int read_gps_gpx(const char *filename, GPSData **data, int *count) {
    FILE *fp = fopen(filename, "r");
    if (!fp) {
        printf("Error: Cannot open file %s\n", filename);
        return -1;
    }
    
    char line[MAX_LINE_LENGTH];
    int capacity = 1000;
    *data = (GPSData *)malloc(capacity * sizeof(GPSData));
    *count = 0;
    
    int in_trkpt = 0;
    double lat = 0, lon = 0, ele = 0, speed = 0, hdop = 1.0;
    char timestamp[64] = {0};
    int fix_type = 3; // Default to 3D fix
    
    printf("Parsing GPX file: %s\n", filename);
    
    while (fgets(line, sizeof(line), fp)) {
        // Check for track point start with lat/lon attributes
        if (strstr(line, "<trkpt") || strstr(line, "<wpt")) {
            in_trkpt = 1;
            // Reset for new point
            ele = 0;
            speed = 0;
            hdop = 1.0;
            timestamp[0] = '\0';
            fix_type = 3;
            
            // Extract lat/lon from attributes
            char *lat_str = strstr(line, "lat=\"");
            char *lon_str = strstr(line, "lon=\"");
            if (lat_str && lon_str) {
                sscanf(lat_str, "lat=\"%lf\"", &lat);
                sscanf(lon_str, "lon=\"%lf\"", &lon);
            }
        }
        
        if (in_trkpt) {
            char *content;
            
            // Extract elevation
            if ((content = extract_tag_content(line, "ele"))) {
                ele = atof(content);
            }
            
            // Extract time
            if ((content = extract_tag_content(line, "time"))) {
                strncpy(timestamp, content, sizeof(timestamp) - 1);
            }
            
            // Extract speed (might be in extensions)
            if ((content = extract_tag_content(line, "speed"))) {
                speed = atof(content);
            }
            
            // Extract HDOP (horizontal dilution of precision)
            if ((content = extract_tag_content(line, "hdop"))) {
                hdop = atof(content);
            }
            
            // Extract fix type
            if ((content = extract_tag_content(line, "fix"))) {
                if (strcmp(content, "3d") == 0) fix_type = 3;
                else if (strcmp(content, "2d") == 0) fix_type = 2;
                else if (strcmp(content, "none") == 0) fix_type = 0;
            }
            
            // Check for end of track point
            if (strstr(line, "</trkpt>") || strstr(line, "</wpt>")) {
                in_trkpt = 0;
                
                // Only add point if we have valid data
                if (timestamp[0] && lat != 0 && lon != 0) {
                    if (*count >= capacity) {
                        capacity *= 2;
                        *data = (GPSData *)realloc(*data, capacity * sizeof(GPSData));
                    }
                    
                    (*data)[*count].timestamp_us = parse_timestamp(timestamp);
                    (*data)[*count].latitude = (int32_t)(lat * 10000000.0);
                    (*data)[*count].longitude = (int32_t)(lon * 10000000.0);
                    (*data)[*count].altitude = (int32_t)(ele * 1000.0);
                    (*data)[*count].speed2d = (int32_t)(speed * 1000.0);
                    (*data)[*count].speed3d = (int32_t)(speed * 1000.0);
                    (*data)[*count].gps_fix = fix_type;
                    (*data)[*count].gps_precision = (uint16_t)(hdop * 100.0);
                    
                    // Debug first few samples
                    if (*count < 3) {
                        printf("Sample %d: timestamp=%s\n", *count, timestamp);
                        printf("  Raw: lat=%.8f, lon=%.8f, ele=%.3f, speed=%.3f\n", 
                               lat, lon, ele, speed);
                        printf("  Fix: %d, HDOP: %.2f\n", fix_type, hdop);
                        printf("  Scaled: lat=%d, lon=%d, alt=%d, speed=%d\n",
                               (*data)[*count].latitude, (*data)[*count].longitude,
                               (*data)[*count].altitude, (*data)[*count].speed2d);
                    }
                    (*count)++;
                }
            }
        }
    }
    
    fclose(fp);
    printf("\nRead %d GPS samples from %s\n", *count, filename);
    
    if (*count > 0) {
        printf("First timestamp: %llu us\n", (*data)[0].timestamp_us);
        printf("Last timestamp:  %llu us\n", (*data)[*count-1].timestamp_us);
        printf("Duration: %.2f seconds\n", 
               ((*data)[*count-1].timestamp_us - (*data)[0].timestamp_us) / 1000000.0);
    }
    
    return 0;
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("Usage: %s <input.gpx> <output.mp4>\n", argv[0]);
        printf("\nConverts GPX track data directly to GPMF format in MP4\n");
        printf("Includes GPS fix quality and precision data for better compatibility\n");
        return -1;
    }
    
    GPSData *gps_data = NULL;
    int gps_count = 0;
    
    printf("=== GPX to GPMF Converter ===\n\n");
    
    // Read GPS data from GPX
    if (read_gps_gpx(argv[1], &gps_data, &gps_count) != 0) {
        return -1;
    }
    
    if (gps_count == 0) {
        printf("Error: No valid GPS data found in GPX file\n");
        free(gps_data);
        return -1;
    }
    
    printf("\n=== Initializing GPMF Writer ===\n");
    
    // Initialize GPMF writer
    size_t gpmfhandle = GPMFWriteServiceInit();
    size_t mp4_handle = OpenMP4Export(argv[2], 1000, 1001);
    
    if (!gpmfhandle) {
        printf("Error: Failed to initialize GPMF service\n");
        free(gps_data);
        return -1;
    }
    printf("GPMF service initialized: handle=%zu\n", gpmfhandle);
    
    if (!mp4_handle) {
        printf("Error: Failed to create MP4 file\n");
        GPMFWriteServiceClose(gpmfhandle);
        free(gps_data);
        return -1;
    }
    printf("MP4 file created: %s (handle=%zu)\n", argv[2], mp4_handle);
    
    // Create GPS stream
    char gps_buffer[32768];
    size_t gps_handle = GPMFWriteStreamOpen(gpmfhandle, GPMF_CHANNEL_TIMED, 
                                            GPMF_DEVICE_ID_CAMERA, "GoPro", 
                                            gps_buffer, sizeof(gps_buffer));
    
    if (!gps_handle) {
        printf("Error: Failed to create GPS stream\n");
        CloseExport(mp4_handle);
        GPMFWriteServiceClose(gpmfhandle);
        free(gps_data);
        return -1;
    }
    printf("GPS stream created: handle=%zu\n", gps_handle);
    
    printf("\n=== Adding Stream Metadata ===\n");
    
    // Add GPS5 stream metadata
    char stream_name[] = "GPS (Latitude, Longitude, Altitude, 2D speed, 3D speed)";
    int ret = GPMFWriteStreamStore(gps_handle, GPMF_KEY_STREAM_NAME, 
                        GPMF_TYPE_STRING_ASCII, strlen(stream_name), 1, 
                        stream_name, GPMF_FLAGS_STICKY);
    printf("Stream name: %s (ret=%d)\n", stream_name, ret);
    
    char gps_type[] = "lllll";
    ret = GPMFWriteStreamStore(gps_handle, GPMF_KEY_TYPE, 
                        GPMF_TYPE_STRING_ASCII, strlen(gps_type), 1, 
                        gps_type, GPMF_FLAGS_STICKY);
    printf("GPS type: %s (ret=%d)\n", gps_type, ret);
    
    int32_t gps_scale[5] = {10000000, 10000000, 1000, 1000, 1000};
    ret = GPMFWriteStreamStore(gps_handle, GPMF_KEY_SCALE, 
                        GPMF_TYPE_SIGNED_LONG, sizeof(int32_t), 5, 
                        gps_scale, GPMF_FLAGS_STICKY);
    printf("GPS scale: [%d, %d, %d, %d, %d] (ret=%d)\n", 
           gps_scale[0], gps_scale[1], gps_scale[2], gps_scale[3], gps_scale[4], ret);
    
    char units[] = "deg\0deg\0m\0m/s\0m/s";
    ret = GPMFWriteStreamStore(gps_handle, GPMF_KEY_SI_UNITS, 
                        GPMF_TYPE_STRING_ASCII, sizeof(units), 1, 
                        units, GPMF_FLAGS_STICKY);
    printf("GPS units added (ret=%d)\n", ret);
    
    // Flush sticky data
    char buffer[32768];
    uint32_t *payload = NULL;
    uint32_t payload_size = 0;
    GPMFWriteGetPayload(gpmfhandle, GPMF_CHANNEL_TIMED, 
                       (uint32_t *)buffer, sizeof(buffer), 
                       &payload, &payload_size);
    printf("Flushed sticky data: %d bytes\n", payload_size);
    
    // Write GPS samples
    printf("\n=== Writing GPS Samples ===\n");
    uint64_t first_timestamp = gps_data[0].timestamp_us;
    int payload_count = 0;
    int samples_written = 0;
    
    for (int i = 0; i < gps_count; i++) {
        int32_t gps5_data[5];
        gps5_data[0] = gps_data[i].latitude;
        gps5_data[1] = gps_data[i].longitude;
        gps5_data[2] = gps_data[i].altitude;
        gps5_data[3] = gps_data[i].speed2d;
        gps5_data[4] = gps_data[i].speed3d;
        
        uint64_t rel_timestamp = gps_data[i].timestamp_us - first_timestamp;
        
        // Write GPS5 data
        ret = GPMFWriteStreamStoreStamped(gps_handle, STR2FOURCC("GPS5"), 
                                   GPMF_TYPE_SIGNED_LONG, sizeof(int32_t) * 5, 
                                   1, gps5_data, GPMF_FLAGS_NONE, 
                                   rel_timestamp);
        
        if (ret != GPMF_OK) {
            printf("Error writing GPS5 sample %d: ret=%d\n", i, ret);
        } else {
            samples_written++;
        }
        
        // Write GPS fix quality (GPSF)
        ret = GPMFWriteStreamStoreStamped(gps_handle, STR2FOURCC("GPSF"), 
                                   GPMF_TYPE_UNSIGNED_SHORT, sizeof(uint16_t), 
                                   1, &gps_data[i].gps_fix, GPMF_FLAGS_NONE, 
                                   rel_timestamp);
        
        // Write GPS precision (GPSP)
        ret = GPMFWriteStreamStoreStamped(gps_handle, STR2FOURCC("GPSP"), 
                                   GPMF_TYPE_UNSIGNED_SHORT, sizeof(uint16_t), 
                                   1, &gps_data[i].gps_precision, GPMF_FLAGS_NONE, 
                                   rel_timestamp);
        
        // Debug first few samples
        if (i < 3) {
            printf("Sample %d written:\n", i);
            printf("  GPS5: [%d, %d, %d, %d, %d]\n", 
                   gps5_data[0], gps5_data[1], gps5_data[2], 
                   gps5_data[3], gps5_data[4]);
            printf("  Fix: %d, Precision: %d\n", 
                   gps_data[i].gps_fix, gps_data[i].gps_precision);
            printf("  Timestamp: %llu us (%.6f sec)\n", 
                   rel_timestamp, rel_timestamp / 1000000.0);
        }
        
        // Write payload periodically
        if ((i > 0 && i % GPS_SAMPLES_PER_PAYLOAD == 0) || i == gps_count - 1) {
            uint64_t window_time = rel_timestamp;
            GPMFWriteGetPayloadWindow(gpmfhandle, GPMF_CHANNEL_TIMED, 
                                     (uint32_t *)buffer, sizeof(buffer), 
                                     &payload, &payload_size, window_time);
            
            if (payload_size > 0) {
                ExportPayload(mp4_handle, payload, payload_size);
                payload_count++;
                printf("Payload #%d: %d bytes, time=%.3f sec, samples=%d\n", 
                       payload_count, payload_size, window_time / 1000000.0,
                       (i % GPS_SAMPLES_PER_PAYLOAD == 0) ? GPS_SAMPLES_PER_PAYLOAD : (i % GPS_SAMPLES_PER_PAYLOAD + 1));
                
                // Print first payload structure
                if (payload_count == 1) {
                    printf("\n=== First Payload GPMF Structure ===\n");
                    GPMF_stream gs;
                    if (GPMF_OK == GPMF_Init(&gs, payload, payload_size)) {
                        GPMF_ResetState(&gs);
                        do { 
                            PrintGPMF(&gs);
                        } while (GPMF_OK == GPMF_Next(&gs, GPMF_RECURSE_LEVELS));
                    }
                    printf("=====================================\n\n");
                }
            }
        }
    }
    
    // Cleanup
    printf("\n=== Finalizing ===\n");
    printf("Total samples written: %d/%d\n", samples_written, gps_count);
    printf("Total payloads exported: %d\n", payload_count);
    
    GPMFWriteStreamClose(gps_handle);
    CloseExport(mp4_handle);
    GPMFWriteServiceClose(gpmfhandle);
    free(gps_data);
    
    printf("\nDone! Created %s\n", argv[2]);
    printf("\nTo combine with video:\n");
    printf("ffmpeg -i video.mp4 -i %s -map 0:v:0 -map 0:a:0? -map 1:d:0 -c copy -tag:s:0 gpmd output_final.mp4\n", argv[2]);
    
    return 0;
}

You’ll need to compile this using gcc (I did this on a linux machine). Example below:

gcc -o gps_to_gpmf gps_to_gpmf.c GPMF_writer.c demo/GPMF_mp4writer.c demo/GPMF_parser.c demo/GPMF_print.c -I. -Idemo -lm -D_FILE_OFFSET_BITS=64

You can then run the following command to create the output file:

./gpx_to_gpmf DJI_20251012143147_0001_D_RideWithGPS.gpx output.mp4

Step 3 – Add the GPMF track to the original file
For this you’ll need ffmpeg. It’s as simple as adding the track to the original file to create new file with the GPMF track embedded. Example below:

ffmpeg -i C:\temp\Rouvy\DJI_20251012143147_0001_D.mp4 -i C:\temp\Rouvy\output.mp4 -map 0:v:0 -map 0:a:0? -map 1:d:0 -c copy -tag:s:0 gpmd C:\temp\Rouvy\DJI_20251012143147_0001_D_final.mp4

That’s all there is to it! I’m sure there are other ways to achive this but this is my method. Good luck!