Last active
November 27, 2016 09:45
-
-
Save grenade/d8a79c6f3b88e9bbad8d to your computer and use it in GitHub Desktop.
Extract geo data from Contour GPS mp4 files using NodeJS and ffmpeg
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.'); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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