gpx-from-gopro 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +2 -2
- package/src/gopro-cache.js +7 -5
- package/src/gopro-cli.js +20 -8
- package/src/gopro.js +7 -0
- package/src/telemetry.js +103 -17
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ No install needed:
|
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
22
|
npx gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
|
|
23
|
-
[--organize DIR] [--yes] [--mode core|ski]
|
|
23
|
+
[--organize DIR] [--yes] [--mode core|ski] [--no-gpx]
|
|
24
24
|
[--html] [--png [--width N] [--height N]]
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -34,7 +34,9 @@ split for restarts/dropouts). A per-file extraction cache (keyed by size+mtime+r
|
|
|
34
34
|
run resume without re-extracting. `--rate HZ` downsamples from the native ~18 Hz; `--tz HOURS`
|
|
35
35
|
overrides the longitude-guessed local date. `--mode core|ski` runs each session through
|
|
36
36
|
[`gpx-stabilizer`](https://www.npmjs.com/package/gpx-stabilizer)'s `stabilizeTrack` before writing
|
|
37
|
-
(omit for the raw extraction).
|
|
37
|
+
(omit for the raw extraction). `--no-gpx` skips writing the merged `.gpx` entirely — extraction,
|
|
38
|
+
caching, and (with `--organize`) reorganizing the source videos still happen normally; `--html`/
|
|
39
|
+
`--png` are unaffected either way (both render from the raw extracted points, not the `.gpx` file).
|
|
38
40
|
|
|
39
41
|
### `--organize DIR` — reorganize the source videos to match the GPX
|
|
40
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gpx-from-gopro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Extract a GoPro video's GPS telemetry as GPX or render-agnostic TrackPoints (points + meta + timezone + UTC anchor).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"gopro",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"directory": "packages/gopro"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"gpx-stabilizer": "^0.
|
|
35
|
+
"gpx-stabilizer": "^0.9.0",
|
|
36
36
|
"egm96-universal": "^1.1.1",
|
|
37
37
|
"gopro-telemetry": "^1.2.11",
|
|
38
38
|
"gpmf-extract": "^0.3.3",
|
package/src/gopro-cache.js
CHANGED
|
@@ -8,15 +8,17 @@ import { basename, join, resolve } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
// Cache schema version: bump whenever the cached record's shape or the
|
|
10
10
|
// extraction output changes, so stale records read as a miss and re-extract.
|
|
11
|
+
// 4: `meta` also carries `createdUtc` (moov `mvhd.creation_time`) — regressStartUtc's
|
|
12
|
+
// disambiguating reference (telemetry.js), needed on every extraction so bumped
|
|
13
|
+
// rather than left graceful (an old v3 record's absent `createdUtc` would silently
|
|
14
|
+
// fall back to the far-less-precise file mtime instead, on every file until it
|
|
15
|
+
// happens to change).
|
|
11
16
|
// 3: points carry `cts` (media offset); the record is { meta, points, streams },
|
|
12
17
|
// where `streams` holds every non-GPS GPMF channel (IMU/scene/exposure/…) as raw
|
|
13
18
|
// cts samples and `meta` also carries camera fields (`model`/`firmware`/`serial`/
|
|
14
|
-
// `mediaId`/`highlights`, from the moov udta).
|
|
15
|
-
// ADDITIVE — an older v3 record without them reads back as `streams: {}` / absent
|
|
16
|
-
// fields (graceful), so while the multi-sensor format is still iterated we clear
|
|
17
|
-
// stale records by hand instead of bumping the version; bump before shipping.
|
|
19
|
+
// `mediaId`/`highlights`, from the moov udta).
|
|
18
20
|
// (v2 was { hasGps, meta, points }, pre-cts.)
|
|
19
|
-
export const CACHE_V =
|
|
21
|
+
export const CACHE_V = 4;
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Cache-record path for a source file: sidecar `<file>.gpxcache.json` by
|
package/src/gopro-cli.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// gpx-from-gopro — extract GoPro GPS into merged GPX, one file per camera per local date.
|
|
3
3
|
// gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ] [--cache-dir DIR | --no-cache]
|
|
4
|
-
// [--organize DIR] [--yes] [--mode core|ski]
|
|
4
|
+
// [--organize DIR] [--yes] [--mode core|ski] [--no-gpx]
|
|
5
5
|
// [--html] [--png [--width N] [--height N]]
|
|
6
6
|
//
|
|
7
|
+
// - --no-gpx: extract + cache (and, with --organize, reorganize) as normal, but skip writing the
|
|
8
|
+
// merged .gpx to disk. `--organize`'s own gpx-move step already no-ops cleanly when the source
|
|
9
|
+
// .gpx was never written (organize.js's executeMove checks existsSync(g.from) first), so the two
|
|
10
|
+
// flags combine with no special-casing needed. --html/--png are unaffected either way — both
|
|
11
|
+
// render straight from the RAW extracted points (see below), never from the written .gpx file.
|
|
12
|
+
//
|
|
7
13
|
// - --mode core|ski: runs the merged points through gpx-stabilizer's own `stabilizeTrack` (per
|
|
8
14
|
// recording session — see the `--out` loop below) before writing, instead of shipping today's
|
|
9
15
|
// default raw extraction. --html/--png (below) render under the SAME mode when both are given,
|
|
@@ -69,10 +75,10 @@ const WITH_VALUE = new Set([
|
|
|
69
75
|
"height",
|
|
70
76
|
"mode",
|
|
71
77
|
]);
|
|
72
|
-
const KNOWN_BOOL = new Set(["no-cache", "html", "png", "yes"]);
|
|
78
|
+
const KNOWN_BOOL = new Set(["no-cache", "html", "png", "yes", "no-gpx"]);
|
|
73
79
|
const USAGE =
|
|
74
80
|
"usage: gpx-from-gopro <dir|file.mp4> [...] [--out DIR] [--tz HOURS] [--rate HZ]" +
|
|
75
|
-
" [--cache-dir DIR | --no-cache] [--organize DIR] [--yes] [--mode core|ski]" +
|
|
81
|
+
" [--cache-dir DIR | --no-cache] [--organize DIR] [--yes] [--mode core|ski] [--no-gpx]" +
|
|
76
82
|
" [--html] [--png [--width N] [--height N]]";
|
|
77
83
|
const inputs = [];
|
|
78
84
|
const opts = {};
|
|
@@ -250,15 +256,20 @@ for (const g of groups) {
|
|
|
250
256
|
type: null,
|
|
251
257
|
},
|
|
252
258
|
};
|
|
253
|
-
const path = join(outDir, `${g.name}.gpx`);
|
|
254
|
-
saveGpx(track, path, { creator: "gpx-from-gopro" });
|
|
255
259
|
const npts = tracks.reduce((s, t) => s + t.segments.reduce((s2, seg) => s2 + seg.length, 0), 0);
|
|
256
260
|
const nseg = tracks.reduce((s, t) => s + t.segments.length, 0);
|
|
257
|
-
|
|
261
|
+
const stats = `${npts} pts, ${tracks.length} trk, ${nseg} seg`;
|
|
262
|
+
if (opts["no-gpx"]) {
|
|
263
|
+
written.push(`[skipped] ${g.name}: gpx not written (--no-gpx), ${stats}`);
|
|
264
|
+
} else {
|
|
265
|
+
const path = join(outDir, `${g.name}.gpx`);
|
|
266
|
+
saveGpx(track, path, { creator: "gpx-from-gopro" });
|
|
267
|
+
written.push(`${path} (${stats})`);
|
|
268
|
+
}
|
|
258
269
|
}
|
|
259
270
|
|
|
260
271
|
console.log(`\ndone. processed=${ok} skipped=${skipped} failed=${failed}`);
|
|
261
|
-
for (const w of written) console.log(` -> ${
|
|
272
|
+
for (const w of written) console.log(` -> ${w}`);
|
|
262
273
|
|
|
263
274
|
// ---- --html / --png: eval visualization of each group's merged track, additive to the .gpx above ----
|
|
264
275
|
// (analyzedLayers/analyzedSvg run core's noise-removal pipeline over the flattened group, under the
|
|
@@ -307,7 +318,8 @@ async function promptLine(question) {
|
|
|
307
318
|
}
|
|
308
319
|
|
|
309
320
|
if (opts.organize) {
|
|
310
|
-
|
|
321
|
+
// --out explicit -> leave the .gpx where the user put it; --no-gpx -> there is no .gpx at all
|
|
322
|
+
const includeGpx = !outExplicit && !opts["no-gpx"];
|
|
311
323
|
const plan = planMove(entries, { root: opts.organize, outDir, includeGpx });
|
|
312
324
|
if (plan.files.length === 0) {
|
|
313
325
|
console.log("\n--organize: nothing to move (no successfully-extracted videos).");
|
package/src/gopro.js
CHANGED
|
@@ -116,6 +116,12 @@ function parseHighlights(buf) {
|
|
|
116
116
|
* @property {string | null} model camera model from the firmware (e.g. "HERO5"), or null
|
|
117
117
|
* @property {string | null} serial camera serial (udta `CAME`, hex) — tells two same-model bodies apart
|
|
118
118
|
* @property {string | null} mediaId global media id (udta `GUMI`, hex) — links a recording's chapter files
|
|
119
|
+
* @property {number | null} createdUtc container creation time (moov `mvhd.creation_time`),
|
|
120
|
+
* epoch ms, or null if mp4box didn't report one. Camera-written at record time, so — unlike
|
|
121
|
+
* file mtime — unaffected by a later copy/move; still the camera's own clock (may be off by
|
|
122
|
+
* a fixed local-vs-UTC labelling error, but never off by YEARS the way an unsynced GPS
|
|
123
|
+
* chip's firmware boot-time constant can be). Used as `regressStartUtc`'s disambiguating
|
|
124
|
+
* reference (telemetry.js).
|
|
119
125
|
* @property {number[]} highlights user highlight-tag offsets in ms (udta `HMMT`); [] if none
|
|
120
126
|
*/
|
|
121
127
|
|
|
@@ -177,6 +183,7 @@ export async function probeGoproMeta(path) {
|
|
|
177
183
|
model: goproModel(firmware),
|
|
178
184
|
serial: came ? came.toString("hex") : null,
|
|
179
185
|
mediaId: gumi ? gumi.toString("hex") : null,
|
|
186
|
+
createdUtc: info?.created instanceof Date ? info.created.getTime() : null,
|
|
180
187
|
highlights: parseHighlights(readUdtaRaw(moov, "HMMT")),
|
|
181
188
|
};
|
|
182
189
|
} finally {
|
package/src/telemetry.js
CHANGED
|
@@ -67,25 +67,97 @@ export function recordingStartUtc(points) {
|
|
|
67
67
|
const MIN_REG_POINTS = 5; // too few points → slope is noise
|
|
68
68
|
const MIN_REG_SPAN_MS = 5000; // <5 s of media offset → extrapolating to 0 is unreliable
|
|
69
69
|
const SLOPE_TOL = 0.05; // |slope−1| must be within this (UTC ms vs media ms ⇒ ≈1)
|
|
70
|
+
const MIN_PAIR_SPAN_MS = 1000; // Theil-Sen: skip near-duplicate-cts pairs (unstable slope, tiny denominator)
|
|
71
|
+
const SYNC_RESIDUAL_TOL_MS = 2000; // after the robust fit, >2s off the line predates real time sync
|
|
72
|
+
const REFERENCE_WINDOW_MS = 30 * 24 * 3600 * 1000; // 30 days either side of `referenceUtc`
|
|
70
73
|
|
|
71
74
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
* Robust slope estimate: the MEDIAN of pairwise slopes over a grid-sampled subset
|
|
76
|
+
* (Theil-Sen) — unlike OLS, a minority of wildly-off points can't drag the median,
|
|
77
|
+
* because their slopes land at the extremes of the sorted list, not the middle.
|
|
78
|
+
* O(gridN²) pairs; gridN caps at 200 so even a few thousand points stay cheap.
|
|
79
|
+
* Returns null if every sampled pair fails the `MIN_PAIR_SPAN_MS` separation.
|
|
80
|
+
*/
|
|
81
|
+
function theilSenSlope(pts) {
|
|
82
|
+
const n = pts.length;
|
|
83
|
+
const gridN = Math.min(n, 200);
|
|
84
|
+
const step = Math.max(1, Math.floor(n / gridN));
|
|
85
|
+
const idxs = [];
|
|
86
|
+
for (let i = 0; i < n; i += step) idxs.push(i);
|
|
87
|
+
const slopes = [];
|
|
88
|
+
for (let i = 0; i < idxs.length; i++) {
|
|
89
|
+
for (let j = i + 1; j < idxs.length; j++) {
|
|
90
|
+
const a = pts[idxs[i]];
|
|
91
|
+
const b = pts[idxs[j]];
|
|
92
|
+
const dx = b.cts - a.cts;
|
|
93
|
+
if (Math.abs(dx) < MIN_PAIR_SPAN_MS) continue;
|
|
94
|
+
slopes.push((b.time - a.time) / dx);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!slopes.length) return null;
|
|
98
|
+
slopes.sort((x, y) => x - y);
|
|
99
|
+
return slopes[Math.floor(slopes.length / 2)];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* True recording-start instant (UTC) by **linear regression** of each sample's
|
|
104
|
+
* UTC `time` against its media offset `cts`: `time ≈ intercept + slope·cts`. The
|
|
105
|
+
* recording starts at `cts = 0`, so the intercept is the wall-clock instant of
|
|
106
|
+
* the first video frame — *before* GPS lock, recovering the pre-lock delay that
|
|
107
|
+
* the first-good-fix anchor ignores. `slope` should be ≈ 1 (both axes are
|
|
108
|
+
* milliseconds); a slope far from 1 means the GPS UTC and media clocks disagree,
|
|
109
|
+
* so the extrapolation is not trustworthy.
|
|
110
|
+
*
|
|
111
|
+
* Deliberately **not** gated on `fix` or position (unlike {@link firstGoodFix}):
|
|
112
|
+
* some chips (observed on a HERO10) sync UTC `time` well before — or entirely
|
|
113
|
+
* independently of — ever reporting a `2d`/`3d` fix, so a `time`+`cts` pair can
|
|
114
|
+
* already be trustworthy while `fix` stays `"none"` and `lat`/`lon` stay stuck at
|
|
115
|
+
* this format's (0,0) "no sample yet" sentinel for the WHOLE clip (a real
|
|
116
|
+
* HERO10 track: 5448 samples, zero ever left (0,0), yet the back four-fifths sync
|
|
117
|
+
* to a slope-1 line). Position and time sync are independent signals — gating the
|
|
118
|
+
* regression on either misses a valid clock.
|
|
119
|
+
*
|
|
120
|
+
* What position-based gating used to catch, this catches differently: a chip
|
|
121
|
+
* commonly emits a PRE-SYNC run of samples (a firmware boot-time constant,
|
|
122
|
+
* unrelated to `cts`) before its real UTC sync kicks in — sometimes as most of
|
|
123
|
+
* the clip, not a minority (observed: an 80/20 split, junk majority). Junk of
|
|
124
|
+
* this shape isn't just scattered noise, either: it's often internally
|
|
125
|
+
* consistent (its OWN slope is also ≈1, e.g. a free-running RTC that just never
|
|
126
|
+
* got corrected) with the WRONG intercept, so a slope check alone can't tell the
|
|
127
|
+
* two clusters apart — whichever has more points wins a plain majority vote,
|
|
128
|
+
* right or wrong. `referenceUtc` (the file's own mtime, or any other rough
|
|
129
|
+
* "roughly when this was recorded" anchor — accurate to a day is plenty) breaks
|
|
130
|
+
* the tie: real satellite time and a firmware boot constant are NEVER
|
|
131
|
+
* confusable at that resolution (a boot constant sits years off), so points
|
|
132
|
+
* more than `REFERENCE_WINDOW_MS` from it are dropped before anything else runs,
|
|
133
|
+
* regardless of which cluster is bigger. Without a `referenceUtc`, this can only
|
|
134
|
+
* fall back to catching a scattered-noise minority via {@link theilSenSlope}'s
|
|
135
|
+
* pairwise-slope median (immune to a minority, not a majority) — pass one
|
|
136
|
+
* whenever it's available.
|
|
79
137
|
*
|
|
80
|
-
* @param {import("gpx-stabilizer").TrackPoint[]} points raw points (need `cts` + `time`
|
|
138
|
+
* @param {import("gpx-stabilizer").TrackPoint[]} points raw points (need `cts` + `time`)
|
|
139
|
+
* @param {{ referenceUtc?: number }} [opts] `referenceUtc`: epoch ms roughly near
|
|
140
|
+
* the true recording time (e.g. file mtime) — disambiguates a majority-junk
|
|
141
|
+
* cluster from the real one; omit only when no such anchor exists at all.
|
|
81
142
|
* @returns {{ startUtc: number, slope: number, n: number } | null} null when too
|
|
82
143
|
* few points, too short a media span, or `cts` is unavailable
|
|
83
144
|
*/
|
|
84
|
-
export function regressStartUtc(points) {
|
|
85
|
-
|
|
86
|
-
|
|
145
|
+
export function regressStartUtc(points, { referenceUtc } = {}) {
|
|
146
|
+
let pts = (points ?? []).filter((p) => p && isFiniteNum(p.time) && isFiniteNum(p.cts));
|
|
147
|
+
if (isFiniteNum(referenceUtc)) {
|
|
148
|
+
pts = pts.filter((p) => Math.abs(p.time - referenceUtc) <= REFERENCE_WINDOW_MS);
|
|
149
|
+
}
|
|
150
|
+
if (pts.length < MIN_REG_POINTS) return null;
|
|
151
|
+
|
|
152
|
+
const robustSlope = theilSenSlope(pts);
|
|
153
|
+
if (robustSlope == null) return null;
|
|
154
|
+
const intercepts = pts.map((p) => p.time - robustSlope * p.cts).sort((a, b) => a - b);
|
|
155
|
+
const robustIntercept = intercepts[Math.floor(intercepts.length / 2)];
|
|
156
|
+
const survivors = pts.filter(
|
|
157
|
+
(p) => Math.abs(p.time - (robustIntercept + robustSlope * p.cts)) < SYNC_RESIDUAL_TOL_MS,
|
|
87
158
|
);
|
|
88
|
-
|
|
159
|
+
|
|
160
|
+
const n = survivors.length;
|
|
89
161
|
if (n < MIN_REG_POINTS) return null;
|
|
90
162
|
// Centre x (cts) and y (time) before the least-squares sums: UTC ms (~1.7e12)
|
|
91
163
|
// and cts ms make the raw normal equations lose precision to catastrophic
|
|
@@ -95,7 +167,7 @@ export function regressStartUtc(points) {
|
|
|
95
167
|
let sy = 0;
|
|
96
168
|
let minC = Number.POSITIVE_INFINITY;
|
|
97
169
|
let maxC = Number.NEGATIVE_INFINITY;
|
|
98
|
-
for (const p of
|
|
170
|
+
for (const p of survivors) {
|
|
99
171
|
sx += p.cts;
|
|
100
172
|
sy += p.time;
|
|
101
173
|
if (p.cts < minC) minC = p.cts;
|
|
@@ -106,7 +178,7 @@ export function regressStartUtc(points) {
|
|
|
106
178
|
const my = sy / n;
|
|
107
179
|
let sxx = 0;
|
|
108
180
|
let sxy = 0;
|
|
109
|
-
for (const p of
|
|
181
|
+
for (const p of survivors) {
|
|
110
182
|
const dx = p.cts - mx;
|
|
111
183
|
sxx += dx * dx;
|
|
112
184
|
sxy += dx * (p.time - my);
|
|
@@ -122,10 +194,11 @@ export function regressStartUtc(points) {
|
|
|
122
194
|
* true-start when its slope passes the quality gate; otherwise falls back to the
|
|
123
195
|
* first good fix (the pre-lock delay is then unknown and left in).
|
|
124
196
|
* @param {import("gpx-stabilizer").TrackPoint[]} points raw points
|
|
197
|
+
* @param {{ referenceUtc?: number }} [opts] forwarded to {@link regressStartUtc}
|
|
125
198
|
* @returns {{ startUtc: number | null, confidence: 'gps' | null, verified: boolean, slope: number | null }}
|
|
126
199
|
*/
|
|
127
|
-
export function resolveStartUtc(points) {
|
|
128
|
-
const reg = regressStartUtc(points);
|
|
200
|
+
export function resolveStartUtc(points, opts) {
|
|
201
|
+
const reg = regressStartUtc(points, opts);
|
|
129
202
|
const verified = reg != null && Math.abs(reg.slope - 1) <= SLOPE_TOL;
|
|
130
203
|
const startUtc = verified ? reg.startUtc : recordingStartUtc(points).startUtc;
|
|
131
204
|
return {
|
|
@@ -241,7 +314,20 @@ export async function readGoproTelemetry(path, opts = {}) {
|
|
|
241
314
|
// {lat,lon,ele,time}, dropping the `fix` + `cts` that good-fix selection and the
|
|
242
315
|
// start regression rely on.
|
|
243
316
|
const timezone = timezoneOfPoints(raw);
|
|
244
|
-
|
|
317
|
+
// regressStartUtc's disambiguating reference (its own doc): prefer the camera's
|
|
318
|
+
// own container creation time (meta.createdUtc, moov mvhd — set once at record
|
|
319
|
+
// time) over file mtime, which drifts whenever the file is later copied/moved
|
|
320
|
+
// (observed: over a MONTH off on a real clip, well past a plausible window) —
|
|
321
|
+
// mtime is only a fallback for the rare file whose moov didn't yield one.
|
|
322
|
+
let referenceUtc = meta.createdUtc;
|
|
323
|
+
if (referenceUtc == null) {
|
|
324
|
+
try {
|
|
325
|
+
referenceUtc = statSync(path).mtimeMs;
|
|
326
|
+
} catch {
|
|
327
|
+
/* no reference at all — regressStartUtc falls back to unanchored (minority-only) robustness */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const clock = resolveStartUtc(raw, { referenceUtc });
|
|
245
331
|
|
|
246
332
|
// resample runs on cleaned survivors, so it IMPLIES stabilize; an explicit
|
|
247
333
|
// `stabilize: false` alongside `resample` is contradictory.
|