Specific tag filters (-GPSLatitude etc.) cause exiftool -ee to emit all values of each tag grouped together rather than interleaved per sample. The -a flag (allow duplicates) with no tag filter produces the correct interleaved per-DEVC-block structure that the awk state machine requires. Also removes the broken NR%6 positional awk in favour of tag-name matching with FS=' *: *', which is robust against whitespace padding in tag names. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -1,186 +0,0 @@ |
||
| 1 |
-#!/usr/bin/env python3 |
|
| 2 |
-""" |
|
| 3 |
-Extract first valid GPS fix from a GoPro GPMF binary stream. |
|
| 4 |
- |
|
| 5 |
-Two usage modes: |
|
| 6 |
- python3 extract_gpmf_gps.py <gpmf.bin> -- parse pre-extracted binary |
|
| 7 |
- python3 extract_gpmf_gps.py <file.mp4> -- extract GPMF stream via ffmpeg, then parse |
|
| 8 |
- |
|
| 9 |
-Output (stdout): lat lon alt |
|
| 10 |
- lat/lon in decimal degrees (negative = S/W) |
|
| 11 |
- alt in metres above sea level |
|
| 12 |
- |
|
| 13 |
-Exit code: 0 if a valid fix was found, 1 otherwise. |
|
| 14 |
-""" |
|
| 15 |
- |
|
| 16 |
-import struct |
|
| 17 |
-import sys |
|
| 18 |
-import os |
|
| 19 |
-import subprocess |
|
| 20 |
-import tempfile |
|
| 21 |
- |
|
| 22 |
- |
|
| 23 |
-def _iter_klv(data, offset=0): |
|
| 24 |
- """Yield (key, type_char, size, repeat, payload) for each KLV record.""" |
|
| 25 |
- end = len(data) |
|
| 26 |
- while offset + 8 <= end: |
|
| 27 |
- key = data[offset:offset + 4] |
|
| 28 |
- if all(b == 0 for b in key): |
|
| 29 |
- break |
|
| 30 |
- type_char = chr(data[offset + 4]) |
|
| 31 |
- size = data[offset + 5] |
|
| 32 |
- repeat = struct.unpack(">H", data[offset + 6:offset + 8])[0]
|
|
| 33 |
- total = size * repeat |
|
| 34 |
- payload = data[offset + 8:offset + 8 + total] |
|
| 35 |
- yield key, type_char, size, repeat, payload |
|
| 36 |
- padded = (total + 3) & ~3 |
|
| 37 |
- offset += 8 + padded |
|
| 38 |
- |
|
| 39 |
- |
|
| 40 |
-def _parse_scal(type_char, payload): |
|
| 41 |
- """Return list of scale factors from a SCAL record.""" |
|
| 42 |
- if type_char in ("s",):
|
|
| 43 |
- return [struct.unpack(">h", payload[i:i + 2])[0] for i in range(0, len(payload), 2)]
|
|
| 44 |
- if type_char in ("S",):
|
|
| 45 |
- return [struct.unpack(">H", payload[i:i + 2])[0] for i in range(0, len(payload), 2)]
|
|
| 46 |
- if type_char in ("l",):
|
|
| 47 |
- return [struct.unpack(">i", payload[i:i + 4])[0] for i in range(0, len(payload), 4)]
|
|
| 48 |
- if type_char in ("L",):
|
|
| 49 |
- return [struct.unpack(">I", payload[i:i + 4])[0] for i in range(0, len(payload), 4)]
|
|
| 50 |
- return [1] |
|
| 51 |
- |
|
| 52 |
- |
|
| 53 |
-def _first_gps5_fix(strm_payload): |
|
| 54 |
- """ |
|
| 55 |
- Scan a STRM payload for a valid GPS5 fix. |
|
| 56 |
- Returns (lat, lon, alt) or None. |
|
| 57 |
- Valid = GPSF >= 2 (2D or 3D fix), GPSP <= 500 (DOP*100), non-zero coords. |
|
| 58 |
- """ |
|
| 59 |
- scal = None |
|
| 60 |
- gpsf = 0 |
|
| 61 |
- gpsp = 9999 |
|
| 62 |
- |
|
| 63 |
- for key, tc, size, repeat, payload in _iter_klv(strm_payload): |
|
| 64 |
- if key == b"SCAL": |
|
| 65 |
- scal = _parse_scal(tc, payload) |
|
| 66 |
- elif key == b"GPSF": |
|
| 67 |
- try: |
|
| 68 |
- gpsf = struct.unpack(">I", payload[:4])[0]
|
|
| 69 |
- except struct.error: |
|
| 70 |
- pass |
|
| 71 |
- elif key == b"GPSP": |
|
| 72 |
- try: |
|
| 73 |
- gpsp = struct.unpack(">H", payload[:2])[0]
|
|
| 74 |
- except struct.error: |
|
| 75 |
- pass |
|
| 76 |
- elif key == b"GPS5": |
|
| 77 |
- if scal is None or gpsf < 2 or gpsp > 500 or size < 20: |
|
| 78 |
- continue |
|
| 79 |
- s_lat = scal[0] if len(scal) > 0 else 1 |
|
| 80 |
- s_lon = scal[1] if len(scal) > 1 else s_lat |
|
| 81 |
- s_alt = scal[2] if len(scal) > 2 else 1 |
|
| 82 |
- for i in range(repeat): |
|
| 83 |
- off = i * size |
|
| 84 |
- try: |
|
| 85 |
- lat_r, lon_r, alt_r = struct.unpack(">iii", payload[off:off + 12])
|
|
| 86 |
- except struct.error: |
|
| 87 |
- break |
|
| 88 |
- lat = lat_r / s_lat |
|
| 89 |
- lon = lon_r / s_lon |
|
| 90 |
- alt = alt_r / s_alt |
|
| 91 |
- if abs(lat) > 0.001 and abs(lon) > 0.001: |
|
| 92 |
- return lat, lon, alt |
|
| 93 |
- return None |
|
| 94 |
- |
|
| 95 |
- |
|
| 96 |
-def parse_gpmf(data): |
|
| 97 |
- """ |
|
| 98 |
- Walk all DEVC blocks in a raw GPMF binary and return the first valid GPS fix. |
|
| 99 |
- Returns (lat, lon, alt) or None. |
|
| 100 |
- """ |
|
| 101 |
- for key, tc, size, repeat, devc_payload in _iter_klv(data): |
|
| 102 |
- if key != b"DEVC": |
|
| 103 |
- continue |
|
| 104 |
- for skey, stc, ssize, srepeat, strm_payload in _iter_klv(devc_payload): |
|
| 105 |
- if skey != b"STRM": |
|
| 106 |
- continue |
|
| 107 |
- result = _first_gps5_fix(strm_payload) |
|
| 108 |
- if result: |
|
| 109 |
- return result |
|
| 110 |
- return None |
|
| 111 |
- |
|
| 112 |
- |
|
| 113 |
-def extract_gpmf_stream(mp4_path): |
|
| 114 |
- """ |
|
| 115 |
- Use ffprobe to find the GPMF stream index, then extract it with ffmpeg. |
|
| 116 |
- Returns the binary data or None. |
|
| 117 |
- """ |
|
| 118 |
- try: |
|
| 119 |
- probe = subprocess.run( |
|
| 120 |
- ["ffprobe", "-v", "quiet", "-show_streams", "-print_format", "json", mp4_path], |
|
| 121 |
- capture_output=True, timeout=30 |
|
| 122 |
- ) |
|
| 123 |
- import json |
|
| 124 |
- streams = json.loads(probe.stdout).get("streams", [])
|
|
| 125 |
- gpmd_idx = next( |
|
| 126 |
- (s["index"] for s in streams if s.get("codec_tag_string") == "gpmd"),
|
|
| 127 |
- None |
|
| 128 |
- ) |
|
| 129 |
- if gpmd_idx is None: |
|
| 130 |
- return None |
|
| 131 |
- except Exception: |
|
| 132 |
- return None |
|
| 133 |
- |
|
| 134 |
- with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as tmp: |
|
| 135 |
- tmp_path = tmp.name |
|
| 136 |
- |
|
| 137 |
- try: |
|
| 138 |
- subprocess.run( |
|
| 139 |
- ["ffmpeg", "-y", "-v", "quiet", "-i", mp4_path, |
|
| 140 |
- "-map", f"0:{gpmd_idx}", "-c", "copy", "-f", "rawvideo", tmp_path],
|
|
| 141 |
- check=True, timeout=60 |
|
| 142 |
- ) |
|
| 143 |
- with open(tmp_path, "rb") as f: |
|
| 144 |
- return f.read() |
|
| 145 |
- except Exception: |
|
| 146 |
- return None |
|
| 147 |
- finally: |
|
| 148 |
- try: |
|
| 149 |
- os.unlink(tmp_path) |
|
| 150 |
- except OSError: |
|
| 151 |
- pass |
|
| 152 |
- |
|
| 153 |
- |
|
| 154 |
-def main(): |
|
| 155 |
- if len(sys.argv) != 2: |
|
| 156 |
- print(f"Usage: {sys.argv[0]} <file.bin|file.mp4>", file=sys.stderr)
|
|
| 157 |
- sys.exit(1) |
|
| 158 |
- |
|
| 159 |
- path = sys.argv[1] |
|
| 160 |
- ext = os.path.splitext(path)[1].lower() |
|
| 161 |
- |
|
| 162 |
- if ext in (".mp4", ".lrv", ".mov"):
|
|
| 163 |
- data = extract_gpmf_stream(path) |
|
| 164 |
- else: |
|
| 165 |
- try: |
|
| 166 |
- with open(path, "rb") as f: |
|
| 167 |
- data = f.read() |
|
| 168 |
- except OSError as e: |
|
| 169 |
- print(f"Error reading {path}: {e}", file=sys.stderr)
|
|
| 170 |
- sys.exit(1) |
|
| 171 |
- |
|
| 172 |
- if not data: |
|
| 173 |
- print("No GPMF data found", file=sys.stderr)
|
|
| 174 |
- sys.exit(1) |
|
| 175 |
- |
|
| 176 |
- result = parse_gpmf(data) |
|
| 177 |
- if result is None: |
|
| 178 |
- print("No valid GPS fix found", file=sys.stderr)
|
|
| 179 |
- sys.exit(1) |
|
| 180 |
- |
|
| 181 |
- lat, lon, alt = result |
|
| 182 |
- print(f"{lat:.7f} {lon:.7f} {alt:.3f}")
|
|
| 183 |
- |
|
| 184 |
- |
|
| 185 |
-if __name__ == "__main__": |
|
| 186 |
- main() |
|
@@ -27,8 +27,6 @@ CLEANUP_EMPTY_DIRS=1 |
||
| 27 | 27 |
CLEANUP_MEDIA_SIDECARS=1 # when 1, delete device sidecars (GoPro THM/LRV, Garmin GLV) after successful import |
| 28 | 28 |
SYNC_GPS=0 # when 1, embed first GPS fix from GPMF stream into destination file EXIF |
| 29 | 29 |
CONFLICT_APPLY_ALL="" # suffix|skip after an interactive "all similar" choice |
| 30 |
- |
|
| 31 |
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
| 32 | 30 |
RESOLVED_DESTINATION_PATH="" |
| 33 | 31 |
RESERVED_DESTINATION_PATHS=() |
| 34 | 32 |
|
@@ -1109,17 +1107,36 @@ find_companion_lrv() {
|
||
| 1109 | 1107 |
return 1 |
| 1110 | 1108 |
} |
| 1111 | 1109 |
|
| 1112 |
-# Extract first valid GPS fix from a GoPro video or LRV file. |
|
| 1113 |
-# Uses ffmpeg to pull the GPMF data stream (fast: reads index only, not video frames), |
|
| 1114 |
-# then parses it with the bundled Python script. |
|
| 1115 |
-# Outputs: "lat lon alt" in decimal degrees / metres. Exit 1 if no fix found. |
|
| 1110 |
+# Extract first valid GPS fix from a GoPro video or LRV file using exiftool -ee. |
|
| 1111 |
+# |
|
| 1112 |
+# IMPORTANT: specific tag filters (-GPSLatitude etc.) cause exiftool to emit all |
|
| 1113 |
+# values of each tag grouped together (all MeasureModes, then all Latitudes…), |
|
| 1114 |
+# not interleaved per sample. The -a flag (allow duplicates) with NO tag filter |
|
| 1115 |
+# produces interleaved per-block output: |
|
| 1116 |
+# GPSMeasureMode : 3 <- block-level, once per DEVC |
|
| 1117 |
+# GPSLatitude : 47.1… <- GPS5 sample 1 |
|
| 1118 |
+# GPSLongitude : 27.5… |
|
| 1119 |
+# GPSAltitude : 185… |
|
| 1120 |
+# GPSLatitude : 47.1… <- GPS5 sample 2 |
|
| 1121 |
+# … |
|
| 1122 |
+# GPSMeasureMode : 3 <- next DEVC block |
|
| 1123 |
+# |
|
| 1124 |
+# awk exits on first valid fix → SIGPIPE terminates exiftool early. |
|
| 1125 |
+# Outputs: "lat lon alt" decimal degrees / metres, signed (S/W negative). |
|
| 1116 | 1126 |
extract_gps_first_fix() {
|
| 1117 | 1127 |
local file="$1" |
| 1118 |
- local py="$SCRIPT_DIR/extract_gpmf_gps.py" |
|
| 1119 |
- if [[ ! -f "$py" ]]; then |
|
| 1120 |
- return 1 |
|
| 1121 |
- fi |
|
| 1122 |
- python3 "$py" "$file" 2>/dev/null |
|
| 1128 |
+ exiftool -ee -a -n -s "$file" 2>/dev/null | \ |
|
| 1129 |
+ awk -F ' *: *' ' |
|
| 1130 |
+ $1 == "GPSMeasureMode" { mode = $2+0; lat = ""; lon = "" }
|
|
| 1131 |
+ $1 == "GPSLatitude" { lat = $2+0 }
|
|
| 1132 |
+ $1 == "GPSLongitude" { lon = $2+0 }
|
|
| 1133 |
+ $1 == "GPSAltitude" {
|
|
| 1134 |
+ if (mode >= 2 && (lat+0 > 0.001 || lat+0 < -0.001) \ |
|
| 1135 |
+ && (lon+0 > 0.001 || lon+0 < -0.001)) {
|
|
| 1136 |
+ printf "%.7f %.7f %.3f\n", lat+0, lon+0, $2+0; exit |
|
| 1137 |
+ } |
|
| 1138 |
+ } |
|
| 1139 |
+ ' |
|
| 1123 | 1140 |
} |
| 1124 | 1141 |
|
| 1125 | 1142 |
# Write GPS coordinates into a file using exiftool QuickTime GPS tags. |