Skip to content

Instantly share code, notes, and snippets.

@phryneas
Last active March 3, 2026 22:18
Show Gist options
  • Select an option

  • Save phryneas/375946dcfacc271ee8fbca735aabaf3a to your computer and use it in GitHub Desktop.

Select an option

Save phryneas/375946dcfacc271ee8fbca735aabaf3a to your computer and use it in GitHub Desktop.
Potensic Atom 2 Litchi format converter

Inspired by https://spwoodcock.dev/blog/2026-02-04-potensic-atom-redux/

This is 100% written by Codex and not checked for any sanity. It looks like it might do the job for me but use at your own risk!

Resources:

Potensic JSON -> Litchi CSV

Convert a Potensic-style waypoint JSON file (array of waypoint objects) to a Litchi-importable CSV:

node scripts/potensic-to-litchi-csv.js 1771856078198.json

Optional output path:

node scripts/potensic-to-litchi-csv.js 1771856078198.json mission.csv

If you do not want hover/stay actions added from hoverTime:

node scripts/potensic-to-litchi-csv.js 1771856078198.json --no-hover-action

After import in Mission Hub, set:

  • Heading Mode: Custom (WD)
  • Path Mode: Straight Lines (required for waypoint actions)

Potensic JSON -> KML 3D Path (+ Tour)

Generate a 3D path + waypoint KML for visualization:

node scripts/potensic-to-litchi-csv.js 1771856078198.json --format kml

Or provide your own output path:

node scripts/potensic-to-litchi-csv.js 1771856078198.json flight-path.kml

Disable gx:Tour creation if needed:

node scripts/potensic-to-litchi-csv.js 1771856078198.json --format kml --no-tour

Google Earth Pro playback:

  • Open the KML in Earth Pro.
  • In Places, find Auto Fly Tour and click Play Tour.

Litchi LCHM -> Potensic-style JSON

Reverse-convert a Litchi mission file into Potensic-style waypoint JSON:

node scripts/potensic-to-litchi-csv.js 1771856078198.litchi.lchm --format json

Optional output path:

node scripts/potensic-to-litchi-csv.js 1771856078198.litchi.lchm recovered.json --format json

Notes:

  • Uses the LCHM waypoint table (big-endian record layout) to recover lat, lng, height, yaw, gimbalPitch, speed, hoverTime, and camera action type.
  • fileName values are generated placeholders (point_from_lchm_XXX.jpg) because LCHM does not carry original Potensic photo names.

Note: KML is for visualization. Use Litchi CSV as the mission editing/interchange format.

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
function usage() {
console.log([
'Usage:',
' node scripts/potensic-to-litchi-csv.js <input> [output] [--format csv|kml|json] [--no-hover-action] [--no-tour]',
'',
'Forward conversion (Potensic JSON -> Litchi CSV/KML):',
' node scripts/potensic-to-litchi-csv.js 1771856078198.json',
' node scripts/potensic-to-litchi-csv.js 1771856078198.json --format kml',
'',
'Reverse conversion (Litchi LCHM -> Potensic JSON):',
' node scripts/potensic-to-litchi-csv.js 1771856078198.litchi.lchm --format json'
].join('\n'));
}
function toNumber(value, fieldName, index) {
const n = Number(value);
if (!Number.isFinite(n)) {
throw new Error(`Invalid numeric value for ${fieldName} at waypoint ${index + 1}: ${value}`);
}
return n;
}
function normalizeHeading(deg) {
const wrapped = deg % 360;
return wrapped < 0 ? wrapped + 360 : wrapped;
}
function denormalizeHeading(deg) {
const n = normalizeHeading(deg);
return n > 180 ? n - 360 : n;
}
function inferCameraAction(action) {
switch ((action || '').toUpperCase()) {
case 'PHOTO':
return { type: 1, param: 0 }; // Take Photo
case 'START_RECORD':
return { type: 2, param: 0 }; // Start Recording
case 'STOP_RECORD':
return { type: 3, param: 0 }; // Stop Recording
default:
return null;
}
}
function actionTypeToPotensic(actionTypes) {
if (actionTypes.includes(2)) {
return 'START_RECORD';
}
if (actionTypes.includes(3)) {
return 'STOP_RECORD';
}
if (actionTypes.includes(1)) {
return 'PHOTO';
}
return 'NONE';
}
function csvEscape(value) {
const s = String(value);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function xmlEscape(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function haversineMeters(aLat, aLng, bLat, bLng) {
const r = 6371000;
const dLat = ((bLat - aLat) * Math.PI) / 180;
const dLng = ((bLng - aLng) * Math.PI) / 180;
const la1 = (aLat * Math.PI) / 180;
const la2 = (bLat * Math.PI) / 180;
const s1 = Math.sin(dLat / 2);
const s2 = Math.sin(dLng / 2);
const a = s1 * s1 + Math.cos(la1) * Math.cos(la2) * s2 * s2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return r * c;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function gimbalPitchToTilt(gimbalPitch) {
return clamp(90 + gimbalPitch, 0, 90);
}
function roundTo(value, decimals) {
const p = 10 ** decimals;
return Math.round(value * p) / p;
}
function buildCsvHeader() {
const cols = [
'latitude',
'longitude',
'altitude(m)',
'heading(deg)',
'curvesize(m)',
'rotationdir',
'gimbalmode',
'gimbalpitchangle'
];
for (let i = 1; i <= 15; i += 1) {
cols.push(`actiontype${i}`, `actionparam${i}`);
}
cols.push(
'altitudemode',
'speed(m/s)',
'poi_latitude',
'poi_longitude',
'poi_altitude(m)',
'poi_altitudemode',
'photo_timeinterval',
'photo_distinterval'
);
return cols;
}
function buildCsvWaypointRow(wp, index, includeHoverAction) {
const lat = toNumber(wp.lat, 'lat', index);
const lng = toNumber(wp.lng, 'lng', index);
const alt = toNumber(wp.height, 'height', index);
const yaw = toNumber(wp.yaw ?? 0, 'yaw', index);
const gimbalPitch = toNumber(wp.gimbalPitch ?? 0, 'gimbalPitch', index);
const speed = Number.isFinite(Number(wp.speed)) ? Number(wp.speed) : 0;
const hoverTimeSec = Number.isFinite(Number(wp.hoverTime)) ? Number(wp.hoverTime) : 0;
const row = [
lat,
lng,
alt,
normalizeHeading(yaw),
0,
0,
2,
gimbalPitch
];
const actions = [];
if (includeHoverAction && hoverTimeSec > 0) {
actions.push({ type: 0, param: Math.round(hoverTimeSec * 1000) });
}
const cameraAction = inferCameraAction(wp.action);
if (cameraAction) {
actions.push(cameraAction);
}
while (actions.length < 15) {
actions.push({ type: -1, param: 0 });
}
actions.slice(0, 15).forEach((a) => {
row.push(a.type, a.param);
});
row.push(
0,
speed,
0,
0,
0,
0,
0,
0
);
return row;
}
function buildCsv(parsed, includeHoverAction) {
const header = buildCsvHeader();
const rows = [header];
parsed.forEach((wp, idx) => {
rows.push(buildCsvWaypointRow(wp, idx, includeHoverAction));
});
return rows.map((r) => r.map(csvEscape).join(',')).join('\n') + '\n';
}
function buildKmlTour(parsed, defaultSpeed) {
const lines = [];
lines.push(' <gx:Tour>');
lines.push(' <name>Auto Fly Tour</name>');
lines.push(' <gx:Playlist>');
parsed.forEach((wp, idx) => {
const lat = toNumber(wp.lat, 'lat', idx);
const lng = toNumber(wp.lng, 'lng', idx);
const alt = toNumber(wp.height, 'height', idx);
const yaw = Number.isFinite(Number(wp.yaw)) ? normalizeHeading(Number(wp.yaw)) : 0;
const gimbal = Number.isFinite(Number(wp.gimbalPitch)) ? Number(wp.gimbalPitch) : 0;
const speed = Number.isFinite(Number(wp.speed)) && Number(wp.speed) > 0 ? Number(wp.speed) : defaultSpeed;
let duration = 1.5;
if (idx > 0) {
const prev = parsed[idx - 1];
const dist = haversineMeters(Number(prev.lat), Number(prev.lng), lat, lng);
duration = clamp(dist / speed, 0.5, 8.0);
}
const tilt = gimbalPitchToTilt(gimbal);
const range = clamp(Math.max(20, alt * 2), 20, 500);
lines.push(' <gx:FlyTo>');
lines.push(` <gx:duration>${duration.toFixed(2)}</gx:duration>`);
lines.push(' <gx:flyToMode>smooth</gx:flyToMode>');
lines.push(' <LookAt>');
lines.push(` <longitude>${lng}</longitude>`);
lines.push(` <latitude>${lat}</latitude>`);
lines.push(` <altitude>${alt}</altitude>`);
lines.push(` <heading>${yaw}</heading>`);
lines.push(` <tilt>${tilt}</tilt>`);
lines.push(` <range>${range}</range>`);
lines.push(' <altitudeMode>relativeToGround</altitudeMode>');
lines.push(' </LookAt>');
lines.push(' </gx:FlyTo>');
});
lines.push(' </gx:Playlist>');
lines.push(' </gx:Tour>');
return lines;
}
function buildKml(parsed, docName, includeTour) {
const lines = [];
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
lines.push('<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">');
lines.push(' <Document>');
lines.push(` <name>${xmlEscape(docName)}</name>`);
lines.push(' <Style id="wp"><IconStyle><scale>0.8</scale><Icon><href>http://maps.google.com/mapfiles/kml/paddle/wht-circle.png</href></Icon></IconStyle></Style>');
lines.push(' <Style id="path"><LineStyle><color>ff00a5ff</color><width>3</width></LineStyle></Style>');
lines.push(' <Placemark>');
lines.push(' <name>Flight Path</name>');
lines.push(' <styleUrl>#path</styleUrl>');
lines.push(' <LineString>');
lines.push(' <tessellate>1</tessellate>');
lines.push(' <altitudeMode>relativeToGround</altitudeMode>');
lines.push(' <coordinates>');
parsed.forEach((wp, idx) => {
const lng = toNumber(wp.lng, 'lng', idx);
const lat = toNumber(wp.lat, 'lat', idx);
const alt = toNumber(wp.height, 'height', idx);
lines.push(` ${lng},${lat},${alt}`);
});
lines.push(' </coordinates>');
lines.push(' </LineString>');
lines.push(' </Placemark>');
parsed.forEach((wp, idx) => {
const lng = toNumber(wp.lng, 'lng', idx);
const lat = toNumber(wp.lat, 'lat', idx);
const alt = toNumber(wp.height, 'height', idx);
const yaw = Number.isFinite(Number(wp.yaw)) ? Number(wp.yaw) : 0;
const gimbal = Number.isFinite(Number(wp.gimbalPitch)) ? Number(wp.gimbalPitch) : 0;
const speed = Number.isFinite(Number(wp.speed)) ? Number(wp.speed) : 0;
const hover = Number.isFinite(Number(wp.hoverTime)) ? Number(wp.hoverTime) : 0;
const action = xmlEscape(wp.action || 'NONE');
const fileName = xmlEscape(wp.fileName || '');
lines.push(' <Placemark>');
lines.push(` <name>WP ${String(idx + 1).padStart(2, '0')} - ${action}</name>`);
lines.push(' <styleUrl>#wp</styleUrl>');
lines.push(' <description><![CDATA[' +
`file=${fileName}<br/>speed=${speed} m/s<br/>yaw=${yaw} deg<br/>gimbalPitch=${gimbal} deg<br/>hover=${hover}s` +
']]></description>');
lines.push(' <Point>');
lines.push(' <altitudeMode>relativeToGround</altitudeMode>');
lines.push(` <coordinates>${lng},${lat},${alt}</coordinates>`);
lines.push(' </Point>');
lines.push(' </Placemark>');
});
if (includeTour) {
buildKmlTour(parsed, 5).forEach((line) => lines.push(line));
}
lines.push(' </Document>');
lines.push('</kml>');
return lines.join('\n') + '\n';
}
function readInt32BE(buffer, offset) {
return buffer.readInt32BE(offset);
}
function readUInt32BE(buffer, offset) {
return buffer.readUInt32BE(offset);
}
function readFloatBE(buffer, offset) {
return buffer.readFloatBE(offset);
}
function readDoubleBE(buffer, offset) {
return buffer.readDoubleBE(offset);
}
function isFiniteInRange(value, min, max) {
return Number.isFinite(value) && value >= min && value <= max;
}
function looksLikeWaypointCore(buffer, offset) {
if (offset < 0 || offset + 56 > buffer.length) {
return false;
}
const height = readFloatBE(buffer, offset + 0);
const yaw = readFloatBE(buffer, offset + 8);
const speed = readFloatBE(buffer, offset + 12);
const lat = readDoubleBE(buffer, offset + 20);
const lng = readDoubleBE(buffer, offset + 28);
const gimbalPitch = readInt32BE(buffer, offset + 44);
if (!isFiniteInRange(lat, -90, 90) || !isFiniteInRange(lng, -180, 180)) {
return false;
}
if (!isFiniteInRange(height, -200, 2000) || !isFiniteInRange(speed, 0, 100)) {
return false;
}
if (!isFiniteInRange(yaw, -720, 720) || !isFiniteInRange(gimbalPitch, -180, 180)) {
return false;
}
return true;
}
function looksLikeActionBlock72(buffer, offset) {
if (offset < 0 || offset + 72 > buffer.length) {
return false;
}
const actionType1 = readInt32BE(buffer, offset + 52);
const actionType2 = readInt32BE(buffer, offset + 64);
const actionParam1 = readInt32BE(buffer, offset + 60);
const actionParam2 = readInt32BE(buffer, offset + 68);
const actionTypeOk = isFiniteInRange(actionType1, -1, 20) && isFiniteInRange(actionType2, -1, 20);
const actionParamOk = isFiniteInRange(actionParam1, -1, 2000000) && isFiniteInRange(actionParam2, -1, 2000000);
return actionTypeOk && actionParamOk;
}
function parseLchm(buffer) {
if (buffer.length < 44) {
throw new Error('LCHM file is too small.');
}
const magic = buffer.toString('ascii', 0, 4);
if (magic !== 'lchm') {
throw new Error('Input is not an LCHM file (missing lchm magic).');
}
const waypointCount = readUInt32BE(buffer, 40);
const recordOffset = 44;
const fullRecordSize = 72;
const shortRecordSize = 56;
if (waypointCount === 0) {
return [];
}
const points = [];
let cursor = recordOffset;
for (let i = 0; i < waypointCount; i += 1) {
if (!looksLikeWaypointCore(buffer, cursor)) {
let found = -1;
for (let delta = 4; delta <= 1024; delta += 4) {
const candidate = cursor + delta;
if (looksLikeWaypointCore(buffer, candidate)) {
found = candidate;
break;
}
}
if (found === -1) {
throw new Error(`Unable to locate waypoint record ${i + 1} near byte offset ${cursor}.`);
}
cursor = found;
}
const o = cursor;
let recordSize = fullRecordSize;
const height = readFloatBE(buffer, o + 0);
const yaw = readFloatBE(buffer, o + 8);
const speed = readFloatBE(buffer, o + 12);
const lat = readDoubleBE(buffer, o + 20);
const lng = readDoubleBE(buffer, o + 28);
const gimbalPitch = readInt32BE(buffer, o + 44);
let actionType1 = -1;
let actionParam1 = 0;
let actionType2 = -1;
let actionParam2 = 0;
const hasActionBlock = looksLikeActionBlock72(buffer, o);
const hasShortNext = looksLikeWaypointCore(buffer, o + shortRecordSize);
if (hasActionBlock && !hasShortNext) {
actionType1 = readInt32BE(buffer, o + 52);
actionParam1 = readInt32BE(buffer, o + 60);
actionType2 = readInt32BE(buffer, o + 64);
actionParam2 = readInt32BE(buffer, o + 68);
recordSize = fullRecordSize;
} else if (hasActionBlock && hasShortNext) {
const nextFullAlsoValid = looksLikeWaypointCore(buffer, o + fullRecordSize);
if (nextFullAlsoValid) {
actionType1 = readInt32BE(buffer, o + 52);
actionParam1 = readInt32BE(buffer, o + 60);
actionType2 = readInt32BE(buffer, o + 64);
actionParam2 = readInt32BE(buffer, o + 68);
recordSize = fullRecordSize;
} else {
recordSize = shortRecordSize;
}
} else if (!hasActionBlock && hasShortNext) {
recordSize = shortRecordSize;
} else if (hasActionBlock) {
actionType1 = readInt32BE(buffer, o + 52);
actionParam1 = readInt32BE(buffer, o + 60);
actionType2 = readInt32BE(buffer, o + 64);
actionParam2 = readInt32BE(buffer, o + 68);
recordSize = fullRecordSize;
} else {
recordSize = shortRecordSize;
}
const actionTypes = [actionType1, actionType2].filter((t) => t >= 0);
const hoverMsCandidates = [];
if (actionType1 === 0) {
hoverMsCandidates.push(actionParam1);
}
if (actionType2 === 0) {
hoverMsCandidates.push(actionParam2);
}
const hoverMs = hoverMsCandidates.length > 0 ? Math.max(...hoverMsCandidates) : 0;
points.push({
action: actionTypeToPotensic(actionTypes),
fileName: 'point.jpg',
gimbalPitch: Math.round(gimbalPitch),
gimbalType: 'DEFINE',
height: roundTo(height, 1),
hoverTime: roundTo(hoverMs / 1000, 3),
lat: roundTo(lat, 10),
lng: roundTo(lng, 10),
poiHeight: 50.0,
poiLat: 0.0,
poiLng: 0.0,
poiType: 0,
speed: roundTo(speed, 2),
speedType: 'GLOBAL',
yaw: Math.round(denormalizeHeading(yaw)),
yawType: 'DEFINE',
zoomRatio: 1.0,
zoomType: 'DEFINE'
});
cursor += recordSize;
}
while (points.length > 0) {
const last = points[points.length - 1];
const isTrailingEmpty = last.action === 'NONE'
&& last.lat === 0
&& last.lng === 0
&& last.height === 0
&& last.speed === 0
&& last.hoverTime === 0;
if (!isTrailingEmpty) {
break;
}
points.pop();
}
return points;
}
function parseFormat(args, outputPath) {
const idx = args.indexOf('--format');
if (idx !== -1) {
const value = args[idx + 1];
if (!value) {
throw new Error('Missing value for --format. Use csv, kml, or json.');
}
if (value !== 'csv' && value !== 'kml' && value !== 'json') {
throw new Error(`Unsupported format: ${value}. Use csv, kml, or json.`);
}
return value;
}
const lower = outputPath.toLowerCase();
if (lower.endsWith('.kml')) {
return 'kml';
}
if (lower.endsWith('.json')) {
return 'json';
}
return 'csv';
}
function parsePositional(args) {
const positional = [];
for (let i = 0; i < args.length; i += 1) {
const a = args[i];
if (a === '--format') {
i += 1;
continue;
}
if (!a.startsWith('--')) {
positional.push(a);
}
}
return positional;
}
function main() {
const args = process.argv.slice(2);
if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
usage();
process.exit(args.length < 1 ? 1 : 0);
}
const includeHoverAction = !args.includes('--no-hover-action');
const includeTour = !args.includes('--no-tour');
const positional = parsePositional(args);
if (positional.length < 1) {
throw new Error('Missing input path.');
}
const inputPath = path.resolve(positional[0]);
const inputExt = path.extname(inputPath).toLowerCase();
const outputPathArg = positional[1] ? path.resolve(positional[1]) : null;
const baseNoExt = path.resolve(path.dirname(inputPath), path.basename(inputPath, path.extname(inputPath)));
const inferredOutput = outputPathArg
|| (inputExt === '.lchm' ? `${baseNoExt}.potensic.json` : `${baseNoExt}.litchi.csv`);
const format = parseFormat(args, inferredOutput);
if (inputExt === '.json') {
if (format === 'json') {
throw new Error('JSON input cannot be converted to JSON output in this tool. Use csv or kml.');
}
const raw = fs.readFileSync(inputPath, 'utf8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('Input JSON must be a non-empty array of waypoint objects.');
}
const finalOutput = outputPathArg || (format === 'kml' ? `${baseNoExt}.kml` : `${baseNoExt}.litchi.csv`);
const outputText = format === 'kml'
? buildKml(parsed, path.basename(baseNoExt), includeTour)
: buildCsv(parsed, includeHoverAction);
fs.writeFileSync(finalOutput, outputText, 'utf8');
console.log(`Wrote ${parsed.length} waypoints to ${finalOutput}`);
if (format === 'csv') {
console.log('Import in Litchi Mission Hub, then set: Heading Mode = Custom (WD), Path Mode = Straight Lines.');
} else if (includeTour) {
console.log('KML includes gx:Tour. In Google Earth Pro, open Places and click Play Tour on "Auto Fly Tour".');
} else {
console.log('KML generated without gx:Tour.');
}
return;
}
if (inputExt === '.lchm') {
if (format !== 'json') {
throw new Error('LCHM input currently supports only --format json.');
}
const buffer = fs.readFileSync(inputPath);
const parsed = parseLchm(buffer);
const finalOutput = outputPathArg || `${baseNoExt}.potensic.json`;
fs.writeFileSync(finalOutput, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
console.log(`Wrote ${parsed.length} waypoints to ${finalOutput}`);
console.log('Reverse-converted from LCHM waypoint table to Potensic-style JSON schema.');
return;
}
throw new Error(`Unsupported input extension: ${inputExt}. Use .json or .lchm.`);
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment