AI-Powered Trail Mapping for Backcountry Adventures
Last October I got turned around on a ridge above the Little Susitna River. Not dangerously lost — I knew roughly where I was — but the game trail I'd...
Last October I got turned around on a ridge above the Little Susitna River. Not dangerously lost — I knew roughly where I was — but the game trail I'd been following dissolved into a tangle of alder and devil's club, and the GPS track I'd downloaded from a popular hiking app showed a path that clearly hadn't existed in years. Maybe it never did. I stood there holding my phone, looking at a confident blue line drawn across terrain that was impassable, and thought: there has to be a better way to build these maps.
That experience sent me down a rabbit hole that's consumed the last several months. I've been building a trail mapping system that uses AI to process satellite imagery, GPS trace data, and elevation models to generate accurate backcountry trail maps — the kind of maps that actually reflect what's on the ground, not what someone drew in an office ten years ago.
This isn't a product pitch. This is a technical walkthrough of how I built it, what worked, what didn't, and what I learned about combining computer vision with geospatial data.
The Problem with Existing Trail Maps
If you've spent any time in genuine backcountry — not a national park with maintained trails and signposts — you know that most digital trail maps are somewhere between optimistic and fictional. There are a few reasons for this:
- Trails change constantly. Erosion, avalanches, vegetation growth, beaver dams flooding paths. A trail that was clear in 2019 might be gone in 2024.
- Crowd-sourced GPS traces are noisy. People wander off trail, their GPS drifts under tree canopy, and nobody goes back to clean up the data.
- Official maps lag reality by years. The USGS updates topo quads on roughly a 7-year cycle. That's geological time for trail conditions.
- Alaska is particularly bad. Most of the state has no maintained trails at all. You're following game trails, river bars, and ridgelines. The concept of a "trail" is more suggestion than infrastructure.
What I wanted was a system that could take multiple data sources — satellite imagery, crowd-sourced GPS traces, elevation data, vegetation indices — and produce a probabilistic trail map. Not "here's the trail" but "here's where a passable route most likely exists, with confidence levels."
Architecture Overview
The system has four major components, and I'll walk through each one:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Data Ingestion │───▶│ AI Processing │───▶│ Map Generation │
│ (GPS + Imagery) │ │ (Vision + LLM) │ │ (GeoJSON + Tiles)│
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Web Viewer │
│ (Leaflet + API) │
└─────────────────┘
The data ingestion layer pulls in GPS traces from GPX files, satellite imagery tiles from Sentinel-2, and elevation data from the USGS 3DEP program. The AI processing layer runs computer vision on the satellite imagery and uses an LLM to synthesize trail assessments. The map generation layer produces GeoJSON output and raster tiles. And the web viewer lets you actually look at the results on a Leaflet map.
Step 1: Ingesting and Cleaning GPS Traces
The first data source is GPS traces. I pull these from my own Garmin exports and from public GPX repositories. The raw data is messy — GPS wander, signal bounce under canopy, people stopping for lunch and creating clusters of points that look like trail junctions.
var fs = require("fs");
var xml2js = require("xml2js");
function parseGPX(filePath) {
var raw = fs.readFileSync(filePath, "utf-8");
var parser = new xml2js.Parser();
var points = [];
parser.parseString(raw, function(err, result) {
if (err) throw err;
var tracks = result.gpx.trk || [];
tracks.forEach(function(track) {
var segments = track.trkseg || [];
segments.forEach(function(seg) {
var trackpoints = seg.trkpt || [];
trackpoints.forEach(function(pt) {
points.push({
lat: parseFloat(pt.$.lat),
lon: parseFloat(pt.$.lon),
ele: pt.ele ? parseFloat(pt.ele[0]) : null,
time: pt.time ? new Date(pt.time[0]) : null
});
});
});
});
});
return points;
}
function cleanGPSTrace(points, options) {
var maxSpeed = options.maxSpeed || 15; // km/h, reasonable hiking speed
var minDistance = options.minDistance || 2; // meters between points
var cleaned = [points[0]];
for (var i = 1; i < points.length; i++) {
var prev = cleaned[cleaned.length - 1];
var curr = points[i];
var dist = haversineDistance(prev.lat, prev.lon, curr.lat, curr.lon);
var timeDiff = (curr.time - prev.time) / 1000 / 3600; // hours
if (timeDiff > 0) {
var speed = (dist / 1000) / timeDiff; // km/h
if (speed > maxSpeed) continue; // GPS jump, skip it
}
if (dist < minDistance) continue; // Too close, probably stationary
cleaned.push(curr);
}
return cleaned;
}
function haversineDistance(lat1, lon1, lat2, lon2) {
var R = 6371000; // Earth radius in meters
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLon = (lon2 - lon1) * Math.PI / 180;
var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI/180) * Math.cos(lat2 * Math.PI/180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
The cleaning step removes GPS noise — points where you'd have to be running at 30 km/h through dense forest, or where you apparently teleported 500 meters sideways. After cleaning, I run a Douglas-Peucker simplification to reduce point density while preserving the shape of the route.
Step 2: Satellite Imagery Analysis with Vision Models
This is where it gets interesting. Sentinel-2 provides free satellite imagery at 10-meter resolution, updated every 5 days. That's good enough to detect trail corridors in many terrain types — especially above treeline or in areas with distinct vegetation patterns.
I use a vision model to analyze image tiles and identify features that indicate trail presence:
var https = require("https");
var fs = require("fs");
function analyzeTrailTile(imagePath, metadata) {
var imageBuffer = fs.readFileSync(imagePath);
var base64Image = imageBuffer.toString("base64");
var prompt = [
"Analyze this satellite image tile for evidence of hiking trails or paths.",
"Location: " + metadata.lat.toFixed(4) + ", " + metadata.lon.toFixed(4),
"Elevation: " + metadata.elevation + "m",
"Season: " + metadata.season,
"",
"Look for:",
"- Linear features through vegetation (worn paths)",
"- Switchback patterns on slopes",
"- River crossings or ford points",
"- Cairn-like features above treeline",
"- Game trail corridors",
"",
"Return a JSON object with:",
"- trails: array of detected trail segments with start/end pixel coordinates",
"- confidence: 0-1 score for each segment",
"- terrain_type: classification of the terrain",
"- passability: estimated difficulty (easy/moderate/difficult/impassable)",
"- notes: any relevant observations"
].join("\n");
return callVisionAPI(base64Image, prompt);
}
function callVisionAPI(base64Image, prompt) {
return new Promise(function(resolve, reject) {
var body = JSON.stringify({
model: "gpt-4o",
messages: [{
role: "user",
content: [
{ type: "text", text: prompt },
{
type: "image_url",
image_url: {
url: "data:image/png;base64," + base64Image,
detail: "high"
}
}
]
}],
max_tokens: 1500
});
var options = {
hostname: "api.openai.com",
path: "/v1/chat/completions",
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.OPENAI_API_KEY,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
try {
var parsed = JSON.parse(data);
var content = parsed.choices[0].message.content;
resolve(JSON.parse(content));
} catch (e) {
reject(e);
}
});
});
req.on("error", reject);
req.write(body);
req.end();
});
}
I'll be honest — this part took a lot of iteration. Early attempts were unreliable. The vision model would confidently identify "trails" that turned out to be creek beds, or miss obvious paths because they were partially shaded. Three things improved the results dramatically:
Multi-temporal analysis. Instead of one image, I feed the model the same tile from three different dates — early summer, peak summer, and early fall. Trail wear patterns are more visible at different times, and the temporal consistency helps distinguish real trails from shadows.
NDVI pre-processing. I calculate the Normalized Difference Vegetation Index from Sentinel-2's near-infrared and red bands. Trails show up as linear features with lower NDVI values — less vegetation means more bare ground. Feeding the NDVI overlay alongside the RGB image gave the vision model much better signal.
Elevation context. Telling the model the elevation, slope angle, and aspect of each tile dramatically improved its assessments. It stopped calling steep cliff faces "moderate difficulty" and started recognizing that a faint line across a 35-degree slope was probably a switchback, not a creek.
Step 3: Fusing Data Sources with an LLM
Here's where the LLM earns its keep. I have noisy GPS traces that might or might not follow actual trails. I have vision model assessments that might or might not be accurate. I have elevation data and vegetation indices. The question is: given all of this evidence, where do trails actually exist?
I tried pure algorithmic approaches first. Weighted averages, Bayesian fusion, clustering. They all produced mediocre results because the data sources have different failure modes that are hard to model statistically. GPS traces are biased toward popular routes. Satellite imagery misses trails under dense canopy. Elevation models don't know about local conditions.
The LLM approach works better because it can reason about context:
function fuseTrailData(gpsTraces, visionResults, elevationData, bbox) {
var context = {
region: {
bounds: bbox,
terrain: classifyTerrain(elevationData),
vegetation: summarizeVegetation(bbox),
season: getCurrentSeason()
},
gps: {
traceCount: gpsTraces.length,
totalPoints: gpsTraces.reduce(function(sum, t) { return sum + t.length; }, 0),
dateRange: getDateRange(gpsTraces),
heatmapSummary: generateHeatmapSummary(gpsTraces, bbox)
},
vision: {
tilesAnalyzed: visionResults.length,
segmentsDetected: visionResults.reduce(function(sum, r) {
return sum + (r.trails ? r.trails.length : 0);
}, 0),
avgConfidence: calculateAvgConfidence(visionResults)
},
elevation: {
minElevation: elevationData.min,
maxElevation: elevationData.max,
maxSlope: elevationData.maxSlope,
prominentFeatures: elevationData.features
}
};
var prompt = [
"You are a backcountry trail analyst. Given the following data about a region,",
"determine the most likely trail routes and their characteristics.",
"",
"Region data: " + JSON.stringify(context, null, 2),
"",
"GPS heatmap cells with activity:",
JSON.stringify(context.gps.heatmapSummary.slice(0, 50)),
"",
"Vision-detected trail segments:",
JSON.stringify(visionResults.filter(function(r) {
return r.confidence > 0.4;
}).slice(0, 30)),
"",
"Generate a trail network as GeoJSON FeatureCollection. Each trail should be a",
"LineString feature with properties: name (descriptive), confidence (0-1),",
"difficulty (1-5), surface_type, notes, and data_sources (which inputs support it).",
"",
"Rules:",
"- High confidence requires corroboration from multiple data sources",
"- GPS-only trails with few traces get lower confidence",
"- Vision-only trails in dense forest get lower confidence",
"- Trails should follow logical routes (avoid unnecessary elevation gain)",
"- Mark river crossings and exposed sections explicitly"
].join("\n");
return callLLM(prompt);
}
The key insight is that the LLM acts as a reasoning layer that can weigh evidence the way an experienced backcountry navigator would. If GPS traces show people walking along a ridgeline but the satellite imagery shows no visible trail, the LLM can reason that ridgelines above treeline often have informal routes that don't leave visible marks from space. If the vision model detects a clear trail corridor but no GPS traces exist, the LLM considers whether it might be a game trail or an abandoned mining road.
Step 4: Generating Usable Maps
The output of the fusion step is GeoJSON, which is great for developers but not great for actually navigating in the field. I generate two outputs: a tile layer for the web viewer and a simplified GPX file that can be loaded onto a Garmin or phone for offline use.
var turf = require("@turf/turf");
function generateTrailMap(geojson, outputDir) {
// Smooth trail geometries
var smoothed = {
type: "FeatureCollection",
features: geojson.features.map(function(feature) {
if (feature.geometry.type === "LineString") {
var line = turf.lineString(feature.geometry.coordinates);
var simplified = turf.simplify(line, { tolerance: 0.0001 });
var bezier = turf.bezierSpline(simplified);
return Object.assign({}, feature, { geometry: bezier.geometry });
}
return feature;
})
};
// Generate confidence-colored GeoJSON for web display
var styled = {
type: "FeatureCollection",
features: smoothed.features.map(function(feature) {
var confidence = feature.properties.confidence || 0;
var color = confidence > 0.8 ? "#2ecc71" :
confidence > 0.6 ? "#f39c12" :
confidence > 0.4 ? "#e67e22" : "#e74c3c";
feature.properties.stroke = color;
feature.properties.strokeWidth = confidence > 0.6 ? 3 : 2;
feature.properties.strokeOpacity = 0.6 + (confidence * 0.4);
return feature;
})
};
fs.writeFileSync(
outputDir + "/trails.geojson",
JSON.stringify(styled, null, 2)
);
// Generate GPX for offline navigation
var gpx = generateGPX(smoothed);
fs.writeFileSync(outputDir + "/trails.gpx", gpx);
return { geojson: styled, gpxPath: outputDir + "/trails.gpx" };
}
function generateGPX(geojson) {
var gpx = '<?xml version="1.0" encoding="UTF-8"?>\n';
gpx += '<gpx version="1.1" creator="TrailMapper">\n';
geojson.features.forEach(function(feature, i) {
if (feature.geometry.type !== "LineString") return;
var props = feature.properties;
gpx += ' <trk>\n';
gpx += ' <name>' + (props.name || "Trail " + (i+1)) + '</name>\n';
gpx += ' <desc>Confidence: ' + (props.confidence * 100).toFixed(0) + '%';
gpx += ' | Difficulty: ' + props.difficulty + '/5</desc>\n';
gpx += ' <trkseg>\n';
feature.geometry.coordinates.forEach(function(coord) {
gpx += ' <trkpt lat="' + coord[1] + '" lon="' + coord[0] + '">';
if (coord[2]) gpx += '<ele>' + coord[2] + '</ele>';
gpx += '</trkpt>\n';
});
gpx += ' </trkseg>\n';
gpx += ' </trk>\n';
});
gpx += '</gpx>';
return gpx;
}
The confidence-based coloring turns out to be the most useful feature of the whole system. Green trails are well-corroborated — multiple GPS traces, visible in satellite imagery, following logical terrain lines. Orange trails have some evidence but haven't been thoroughly validated. Red trails are speculative — maybe a single GPS trace or a faint line in one satellite image.
What Actually Worked in the Field
I tested the system on three areas near my cabin in Caswell Lakes, plus a section of the Talkeetna Mountains I know well from years of exploring.
Above treeline (Talkeetna Mountains): Excellent results. The satellite imagery analysis performed really well on alpine terrain because trails show up clearly against tundra. The system correctly identified the main ridge routes and even found a game trail traverse I'd used before but never seen on any map.
Boreal forest (Caswell Lakes): Mixed results. Dense spruce canopy defeats satellite imagery almost entirely. The system relied heavily on GPS traces here, which meant it only mapped trails that people had already walked with GPS devices. Not useless, but not the breakthrough I wanted.
River corridors: Surprisingly good. The system correctly identified several gravel bar routes that are seasonal — passable in late summer when water is low, submerged in spring runoff. The multi-temporal satellite analysis caught this, marking them as seasonal routes with appropriate warnings.
The failure that taught me the most: I had the system analyze an area where I knew a good trail existed through a narrow valley. It returned low-confidence results because the GPS traces were sparse and the satellite view was obscured by shadow from the valley walls. I realized the system was actually being honest — it genuinely didn't have enough evidence to be confident. That's better than the hiking app that draws a bold line where no trail exists. I'd rather have a map that says "I'm not sure" than one that lies to me.
Costs and Performance
Running the full pipeline for a 10km x 10km area:
- Sentinel-2 imagery download: Free (ESA open data)
- Elevation data: Free (USGS 3DEP)
- Vision API calls: ~$4-8 depending on tile count and resolution
- LLM fusion calls: ~$2-3 per region
- Processing time: 15-30 minutes per region
Not cheap at scale, but for mapping my local backcountry areas, totally reasonable. I've processed about 20 regions so far and spent maybe $120 total.
Lessons for the AI Builder
A few things I'd pass along to anyone building AI systems that interact with physical reality:
Calibrate against ground truth. I walked every trail the system generated in my local area. About 70% of high-confidence trails matched reality well. About 40% of medium-confidence trails were usable. Low-confidence trails were a coin flip. Those numbers would be meaningless without ground-truthing.
Uncertainty is a feature, not a bug. The most valuable output of this system isn't the trail locations — it's the confidence scores. Knowing what you don't know is more important than being right when you're right. I'd rather have a map that shows me three possible routes with honest uncertainty than one route with false confidence.
Multi-source fusion beats any single source. No single data source was reliable enough on its own. GPS traces without imagery context were just lines on a map. Imagery without GPS corroboration produced too many false positives. Elevation data alone couldn't tell you where trails existed, only where they could exist. The magic is in combining them.
LLMs are surprisingly good at spatial reasoning. I didn't expect the LLM fusion step to work as well as it did. But when you provide structured geospatial data and ask the model to reason about terrain, routes, and evidence quality, it produces results that feel like talking to an experienced outdoorsperson. It knows that trails tend to follow contour lines, that river crossings cluster at wide shallow points, that ridgelines provide natural routes above treeline.
What's Next
I'm working on three improvements:
Temporal monitoring. Running the analysis quarterly to detect trail changes — new blowdowns, erosion, vegetation encroachment. The system should alert me when a trail I've mapped has likely changed.
Difficulty estimation from terrain. Using the elevation model more aggressively to estimate physical difficulty — not just "steep" but "steep, north-facing, likely icy in October." That requires season-aware terrain modeling, which is a fun subproblem.
Community validation layer. Letting other backcountry users submit ground-truth reports — "I walked this trail on this date and it was [accurate/inaccurate]" — and feeding that back into the confidence model.
The broader point is that AI is at its best when it's augmenting human expertise in domains where data is messy, incomplete, and contextual. Backcountry navigation is exactly that kind of domain. You're never going to have perfect data about what the wilderness looks like today. But you can build systems that are honest about uncertainty and that combine multiple imperfect signals into something genuinely useful.
I'm not going to pretend this system replaces the skills of an experienced backcountry navigator. It doesn't. You still need to read terrain, assess conditions, make judgment calls. But having a map that reflects actual current conditions instead of a decade-old approximation — that's the difference between a frustrating bushwhack and an enjoyable ridge walk.
The bear I watched on that ridge last October wasn't following a trail. It was reading the terrain in real time, choosing the path of least resistance based on thirty years of evolved intelligence about how mountains work. We're not there yet with AI trail mapping. But we're getting closer.
Shane Larson is a software engineer and the founder of Grizzly Peak Software. He writes code from a cabin in Caswell Lakes, Alaska, where the trails don't have names and the maps are mostly wrong. His latest book on training large language models is available on Amazon.