Skip to content

Instantly share code, notes, and snippets.

@grenade
Last active November 27, 2016 09:45
Show Gist options
  • Save grenade/d8a79c6f3b88e9bbad8d to your computer and use it in GitHub Desktop.
Save grenade/d8a79c6f3b88e9bbad8d to your computer and use it in GitHub Desktop.
Extract geo data from Contour GPS mp4 files using NodeJS and ffmpeg
var fs = require('fs');
var util = require('util');
var files = ['FILE0502', 'FILE0503', 'FILE0504'];
var dir = '/home/rob/Pictures/2015/03/01';
/** Extend Number object with method to convert numeric degrees to radians */
if (Number.prototype.toRadians === undefined) {
Number.prototype.toRadians = function() { return this * Math.PI / 180; };
}
/** Extend Number object with method to convert radians to numeric (signed) degrees */
if (Number.prototype.toDegrees === undefined) {
Number.prototype.toDegrees = function() { return this * 180 / Math.PI; };
}
function parseSequence (record) {
var p = record[1].replace(/\n/, '').split(' --> ');
return {
id: parseInt(record[0]),
start: p[0],
end: p[1]
};
};
function parseDate(date, time) {
return new Date(
'20' + date.substr(4, 2)
+ '-' +
date.substr(2, 2)
+ '-' +
date.substr(0, 2)
+ 'T' +
time.substr(0, 2)
+ ':' +
time.substr(2, 2)
+ ':' +
time.substr(4, 2)
+ '.' +
time.substr(7)
+ 'Z');
}
function parseFix(fix){
switch (fix){
case '1':
return 'GPS';
case '2':
return 'DGPS';
}
return 'Invalid';
}
function parseSpeed(speed){
var knots = parseFloat(speed);
return {
knots: +knots.toFixed(1),
kph: +(knots * 1.852).toFixed(1),
mph: +(knots * 1.151).toFixed(1)
};
}
function parseGpgga (gpgga) {
/*
$GPGGA,155924.00,4729.86208,N,00728.49162,E,1,07,1.06,343.3,M,47.2,M,,*5D
$GPGGA,170834,4124.8963,N,08151.6838,W,1,05,1.5,280.2,M,-34.0,M,,,*59
Name Example Data Description
Sentence Identifier $GPGGA Global Positioning System Fix Data
Time 170834 17:08:34 UTC
Latitude 4124.8963, N 41d 24.8963' N or 41d 24' 54" N
Longitude 08151.6838, W 81d 51.6838' W or 81d 51' 41" W
Fix Quality:
- 0 = Invalid
- 1 = GPS fix
- 2 = DGPS fix 1 Data is from a GPS fix
Number of Satellites 05 5 Satellites are in view
Horizontal Dilution of Precision (HDOP) 1.5 Relative accuracy of horizontal position
Altitude 280.2, M 280.2 meters above mean sea level
Height of geoid above WGS84 ellipsoid -34.0, M -34.0 meters
Time since last DGPS update blank No last update
DGPS reference station id blank No station id
Checksum *75 Used by program to check for transmission errors
*/
var p = gpgga.replace(/\n/, '').split(',');
var loc = calculateLatLong(p[2], p[3], p[4], p[5]);
return {
timestamp: parseDate(p[9], p[1]),
position: {
latitude: loc.latitude,
longitude: loc.longitude,
altitude: parseFloat(p[9]),
geoidHeight: parseFloat(p[11]),
fix: parseFix(p[6]),
satelites: parseInt(p[7]),
horizontalPrecision: parseFloat(p[8]),
},
station: p[14].split('*')[0],
checksum: p[14].split('*')[1]
};
}
function parseGprmc (gprmc) {
/*
$GPRMC,155924.00,A,4729.86208,N,00728.49162,E,41.762,73.59,010315,,,A*61
$GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a,m*hh
Field #
1 = UTC time of fix
2 = Data status (A=Valid position, V=navigation receiver warning)
3 = Latitude of fix
4 = N or S of longitude
5 = Longitude of fix
6 = E or W of longitude
7 = Speed over ground in knots
8 = Track made good in degrees True
9 = UTC date of fix
10 = Magnetic variation degrees (Easterly var. subtracts from true course)
11 = E or W of magnetic variation
12 = Mode indicator, (A=Autonomous, D=Differential, E=Estimated, N=Data not valid)
13 = Checksum
*/
var p = gprmc.replace(/\n/, '').split(',');
var loc = calculateLatLong(p[3], p[4], p[5], p[6]);
var knots = parseFloat(p[7]);
return {
timestamp: parseDate(p[9], p[1]),
status: p[2],
position: {
latitude: loc.latitude,
longitude: loc.longitude,
speed: parseSpeed(p[7]),
bearing: parseFloat(p[8])//,
//variation: p[10] + p[11]
},
mode: p[12].split('*')[0],
checksum: p[12].split('*')[1]
};
};
function calculateLatLong (lat, latH, long, longH) {
/* http://lakenine.com/reading-and-parsing-gps-sentences-a-linux-example/
1. Divide the integer part of the latitude by 100. 4827 ÷ 100 = 48
2. Get the "minutes" value by taking the remainder of the value divided by 100. 4827.563 - 100 × 48 = 27.563
3. The result of step 2 is the "minutes" part of the degrees. Since there are 60 minutes per degree, divide the result from step 3 by 60 to get the decimal degrees. 27.563 ÷ 60 ≈ 0.45938
4. Add the values from step 2 and 4. 48 + 0.45938 = 48.45938
5. Make the result from step 5 negative if this is a latitude and it's South, or if it's a longitude and it's West.
*/
var latD = parseInt(lat.substr(0, 2));
var latM = parseFloat(lat) - (latD * 100);
var longD = parseInt(long.substr(0, 3));
var longM = parseFloat(long) - (longD * 100);
return {
latitude: (latH === 'S') ? (0 - (latD + (latM / 60))) : (latD + (latM / 60)),
longitude: (longH === 'W') ? (0 - (longD + (longM / 60))) : (longD + (longM / 60))
};
};
function getAverageSpeed(readings){
var sum = 0.0;
var count = 0;
readings.forEach(function (r) {
if(r.gps.gprmc.position.speed.mph){
sum += r.gps.gprmc.position.speed.mph;
count ++;
}
});
return +(sum / count).toFixed(1);
}
function proximity(a, b){
//http://www.movable-type.co.uk/scripts/latlong.html
var R = 6371000; // metres
var φ1 = a.latitude.toRadians();
var φ2 = b.latitude.toRadians();
var Δφ = (b.latitude - a.latitude).toRadians();
var Δλ = (b.longitude - a.longitude).toRadians();
var a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function distance(coordinates, unitOfMeasure){
var distance = 0.0;
for(var i = 1; i < coordinates.length; i++){
distance += proximity({
longitude: coordinates[i-1][0],
latitude: coordinates[i-1][1]
}, {
longitude: coordinates[i][0],
latitude: coordinates[i][1]
});
}
switch(unitOfMeasure){
case 'mile':
return +(distance * 0.000621371).toFixed(2);
case 'km':
return +(distance / 1000).toFixed(2);
}
return +(distance).toFixed(2);
}
var exec = require('child_process').exec;
files.forEach(function (file) {
var mp4 = dir + '/' + file + '.MP4';
var srt = dir + '/' + file + '.srt';
var json = dir + '/' + file + '.json';
var geojson = dir + '/' + file + '.geojson';
try { fs.unlinkSync(srt); } catch (e){}
try { fs.unlinkSync(json); } catch (e){}
try { fs.unlinkSync(geojson); } catch (e){}
exec('ffmpeg -i ' + mp4 + ' -vn -an -codec:s:0.2 srt ' + srt, function (error, stdout, stderr) {
var coordinates = [];
var readings = [];
fs.readFileSync(srt).toString().replace(/\r/g, '').split('\n\n\n').forEach(function (item) {
var record = item.split('\n')
if(record.length > 3 ){
var gprmc = parseGprmc(record[2]);
var gpgga = parseGpgga(record[3]);
var reading = {
sequence: parseSequence(record),
gps: {
gprmc: gprmc,
gpgga: gpgga
}
};
if(gpgga.position.longitude
&& gpgga.position.latitude){
coordinates.push([
gpgga.position.longitude,
gpgga.position.latitude,
gpgga.position.altitude || 0]);
}
readings.push(reading);
//console.log(util.inspect(reading, true, 10));
}
});
var geojsonData = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: coordinates
},
properties: {
highestSpeed: Math.max.apply(Math,readings.map(function(r){return r.gps.gprmc.position.speed.mph || 0;})),
averageSpeed: getAverageSpeed(readings),
distanceCovered: distance(coordinates, 'mile')
}
}
]
};
fs.writeFile(geojson, JSON.stringify(geojsonData), function (err) {
if (err) throw err;
//console.log(geojson + ' saved.');
});
fs.writeFile(json, JSON.stringify({ source: file, readings: readings }), function (err) {
if (err) throw err;
console.log(json
+ ' saved. Highest speed: '
+ geojsonData.features[0].properties.highestSpeed
+ ' mph, average speed: '
+ geojsonData.features[0].properties.averageSpeed
+ ' mph, distance covered: '
+ geojsonData.features[0].properties.distanceCovered
+ ' miles.');
});
});
});
var fs = require('fs');
var util = require('util');
var glob = require('glob');
var exec = require('child_process').exec;
/** Extend Number object with method to convert numeric degrees to radians */
if (Number.prototype.toRadians === undefined) {
Number.prototype.toRadians = function() { return this * Math.PI / 180; };
}
/** Extend Number object with method to convert radians to numeric (signed) degrees */
if (Number.prototype.toDegrees === undefined) {
Number.prototype.toDegrees = function() { return this * 180 / Math.PI; };
}
//var files = ['FILE0505', 'FILE0506'];
//var dir = '/home/rob/Pictures/2015/03/15';
var filePattern = '/home/rob/Pictures/2015/**/*.MP4';
var overwrite = false;
function parseSequence (record) {
var p = record[1].replace(/\n/, '').split(' --> ');
return {
id: parseInt(record[0]),
start: p[0],
end: p[1]
};
};
function parseDate(date, time) {
return new Date(
'20' + date.substr(4, 2)
+ '-' +
date.substr(2, 2)
+ '-' +
date.substr(0, 2)
+ 'T' +
time.substr(0, 2)
+ ':' +
time.substr(2, 2)
+ ':' +
time.substr(4, 2)
+ '.' +
time.substr(7)
+ 'Z');
}
function parseFix(fix){
switch (fix){
case '1':
return 'GPS';
case '2':
return 'DGPS';
}
return 'Invalid';
}
function parseSpeed(speed){
var knots = parseFloat(speed);
return {
knots: +knots.toFixed(1),
kph: +(knots * 1.852).toFixed(1),
mph: +(knots * 1.151).toFixed(1)
};
}
function parseGpgga (gpgga) {
/*
$GPGGA,155924.00,4729.86208,N,00728.49162,E,1,07,1.06,343.3,M,47.2,M,,*5D
$GPGGA,170834,4124.8963,N,08151.6838,W,1,05,1.5,280.2,M,-34.0,M,,,*59
Name Example Data Description
Sentence Identifier $GPGGA Global Positioning System Fix Data
Time 170834 17:08:34 UTC
Latitude 4124.8963, N 41d 24.8963' N or 41d 24' 54" N
Longitude 08151.6838, W 81d 51.6838' W or 81d 51' 41" W
Fix Quality:
- 0 = Invalid
- 1 = GPS fix
- 2 = DGPS fix 1 Data is from a GPS fix
Number of Satellites 05 5 Satellites are in view
Horizontal Dilution of Precision (HDOP) 1.5 Relative accuracy of horizontal position
Altitude 280.2, M 280.2 meters above mean sea level
Height of geoid above WGS84 ellipsoid -34.0, M -34.0 meters
Time since last DGPS update blank No last update
DGPS reference station id blank No station id
Checksum *75 Used by program to check for transmission errors
*/
var p = gpgga.replace(/\n/, '').split(',');
var loc = calculateLatLong(p[2], p[3], p[4], p[5]);
return {
timestamp: parseDate(p[9], p[1]),
position: {
latitude: loc.latitude,
longitude: loc.longitude,
altitude: parseFloat(p[9]),
geoidHeight: parseFloat(p[11]),
fix: parseFix(p[6]),
satelites: parseInt(p[7]),
horizontalPrecision: parseFloat(p[8]),
},
station: p[14].split('*')[0],
checksum: p[14].split('*')[1]
};
}
function parseGprmc (gprmc) {
/*
$GPRMC,155924.00,A,4729.86208,N,00728.49162,E,41.762,73.59,010315,,,A*61
$GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a,m*hh
Field #
1 = UTC time of fix
2 = Data status (A=Valid position, V=navigation receiver warning)
3 = Latitude of fix
4 = N or S of longitude
5 = Longitude of fix
6 = E or W of longitude
7 = Speed over ground in knots
8 = Track made good in degrees True
9 = UTC date of fix
10 = Magnetic variation degrees (Easterly var. subtracts from true course)
11 = E or W of magnetic variation
12 = Mode indicator, (A=Autonomous, D=Differential, E=Estimated, N=Data not valid)
13 = Checksum
*/
var p = gprmc.replace(/\n/, '').split(',');
var loc = calculateLatLong(p[3], p[4], p[5], p[6]);
var knots = parseFloat(p[7]);
return {
timestamp: parseDate(p[9], p[1]),
status: p[2],
position: {
latitude: loc.latitude,
longitude: loc.longitude,
speed: parseSpeed(p[7]),
bearing: parseFloat(p[8])//,
//variation: p[10] + p[11]
},
mode: p[12].split('*')[0],
checksum: p[12].split('*')[1]
};
};
function calculateLatLong (lat, latH, long, longH) {
/* http://lakenine.com/reading-and-parsing-gps-sentences-a-linux-example/
1. Divide the integer part of the latitude by 100. 4827 ÷ 100 = 48
2. Get the "minutes" value by taking the remainder of the value divided by 100. 4827.563 - 100 × 48 = 27.563
3. The result of step 2 is the "minutes" part of the degrees. Since there are 60 minutes per degree, divide the result from step 3 by 60 to get the decimal degrees. 27.563 ÷ 60 ≈ 0.45938
4. Add the values from step 2 and 4. 48 + 0.45938 = 48.45938
5. Make the result from step 5 negative if this is a latitude and it's South, or if it's a longitude and it's West.
*/
var latD = parseInt(lat.substr(0, 2));
var latM = parseFloat(lat) - (latD * 100);
var longD = parseInt(long.substr(0, 3));
var longM = parseFloat(long) - (longD * 100);
return {
latitude: (latH === 'S') ? (0 - (latD + (latM / 60))) : (latD + (latM / 60)),
longitude: (longH === 'W') ? (0 - (longD + (longM / 60))) : (longD + (longM / 60))
};
};
function getAverageSpeed(readings){
var sum = 0.0;
var count = 0;
readings.forEach(function (r) {
if(r.gps.gprmc.position.speed.mph){
sum += r.gps.gprmc.position.speed.mph;
count ++;
}
});
return +(sum / count).toFixed(1);
}
function proximity(a, b){
//http://www.movable-type.co.uk/scripts/latlong.html
var R = 6371000; // metres
var φ1 = a.latitude.toRadians();
var φ2 = b.latitude.toRadians();
var Δφ = (b.latitude - a.latitude).toRadians();
var Δλ = (b.longitude - a.longitude).toRadians();
var a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function distance(coordinates, unitOfMeasure){
var distance = 0.0;
for(var i = 1; i < coordinates.length; i++){
distance += proximity({
longitude: coordinates[i-1][0],
latitude: coordinates[i-1][1]
}, {
longitude: coordinates[i][0],
latitude: coordinates[i][1]
});
}
switch(unitOfMeasure){
case 'mile':
return +(distance * 0.000621371).toFixed(2);
case 'km':
return +(distance / 1000).toFixed(2);
}
return +(distance).toFixed(2);
}
glob(filePattern, null, function (globError, files) {
files.forEach(function (file) {
var mp4 = file;
var srt = file.replace('.MP4', '') + '.srt';
var json = file.replace('.MP4', '') + '.json';
var geojson = file.replace('.MP4', '') + '.geojson';
fs.stat(geojson, function(geojsonStatError) {
//run only if overwrite is set to true or geojson file doesn't exist
if(overwrite || (geojsonStatError !== null && geojsonStatError.code === 'ENOENT')) {
try { fs.unlinkSync(srt); } catch (e){}
try { fs.unlinkSync(json); } catch (e){}
try { fs.unlinkSync(geojson); } catch (e){}
exec('ffmpeg -i ' + mp4 + ' -vn -an -codec:s:0.2 srt ' + srt, function (execFfmpegError, ffmpegStdout, ffmpegStderr) {
//console.log('ffmpeg stdout: ' + ffmpegStdout);
//console.log('ffmpeg stderr: ' + ffmpegStderr);
if (execFfmpegError !== null) {
console.log('Failed to extract subtitle tracks from file: ' + mp4 + '.');
//console.log('exec error: ' + execFfmpegError);
} else {
var coordinates = [];
var readings = [];
fs.readFileSync(srt).toString().replace(/\r/g, '').split('\n\n\n').forEach(function (item) {
var record = item.split('\n')
if(record.length > 3 ){
var gprmc = parseGprmc(record[2]);
var gpgga = parseGpgga(record[3]);
var reading = {
sequence: parseSequence(record),
gps: {
gprmc: gprmc,
gpgga: gpgga
}
};
if(gpgga.position.longitude
&& gpgga.position.latitude){
coordinates.push([
gpgga.position.longitude,
gpgga.position.latitude,
gpgga.position.altitude || 0]);
}
readings.push(reading);
//console.log(util.inspect(reading, true, 10));
}
});
var geojsonData = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: coordinates
},
properties: {
highestSpeed: Math.max.apply(Math,readings.map(function(r){return r.gps.gprmc.position.speed.mph || 0;})),
averageSpeed: getAverageSpeed(readings),
distanceCovered: distance(coordinates, 'mile')
}
}
]
};
fs.writeFile(geojson, JSON.stringify(geojsonData), function (err) {
if (err) throw err;
// --no-open
exec('./node_modules/gist-cli/gist.js ' + geojson, function (execGistError, gistStdout, gistStderr) {
//console.log('gist stdout: ' + gistStdout);
//console.log('gist stderr: ' + gistStderr);
if (execGistError !== null) {
console.log('Failed to upload geojson file to gist.');
} else {
console.log('geojson uploaded to gist.');
}
});
//console.log(geojson + ' saved.');
});
fs.writeFile(json, JSON.stringify({ source: file, readings: readings }), function (err) {
if (err) throw err;
console.log(json
+ ' saved. Highest speed: '
+ geojsonData.features[0].properties.highestSpeed
+ ' mph, average speed: '
+ geojsonData.features[0].properties.averageSpeed
+ ' mph, distance covered: '
+ geojsonData.features[0].properties.distanceCovered
+ ' miles.');
});
}
});
} else {
console.log('Skipping processing for previously processed file: ' + mp4 + '.');
}
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment