EXIF GPS to caption — reverse geocoding
Reads the GPS position from the image's EXIF and writes it onto the picture as a caption bar. With an API key for geocode.maps.co (OpenStreetMap data) it resolves the place name; without one it shows the coordinates — which is what the rendered example does.
INPUT
OUTPUT
JavaScript
// Reverse-geocode from EXIF GPS + caption — "EXIF in, web out" showcase.
// demo_geocode.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: API_KEY:string=
//!PARAM: BAR_OPACITY:number=0.55,min=0,max=1,step=0.05
//!PARAM: TEXT_COLOR:string=#ffffff
//!PARAM: MAX_EDGE:integer=1280,min=0,max=4096
//!HTTP_ALLOW: geocode.maps.co
// Reads the GPS position from the image's EXIF, looks up the place name
// at geocode.maps.co (OpenStreetMap / Nominatim), writes it onto the
// photo as a caption, and stores the address in the image metadata.
// Two engine features in one demo: EXIF extraction + Engine.fetch().
//
// Privacy: the GPS coordinates are sent to geocode.maps.co. You need a
// free key (https://geocode.maps.co) in the API_KEY parameter — without
// one the script just shows the raw coordinates.
const img = Engine.loadImage(INPUT);
const exif = img.meta().exif || {};
// Hier ist Verkleinern richtig, anders als bei den Rausch-Beispielen:
// Gezeigt wird eine Bildunterschrift auf einem Foto, kein Pixelbefund.
// Wichtig ist nur die Reihenfolge — erst verkleinern, dann beschriften,
// sonst schrumpft die Schrift mit und wird unleserlich. Das EXIF ist zu
// diesem Zeitpunkt bereits ausgelesen; die Vorlage bleibt als
// JPEG mit GPS erhalten, weil genau das hier vorgefuehrt wird.
if (MAX_EDGE > 0 && Math.max(img.width, img.height) > MAX_EDGE) {
const sc = MAX_EDGE / Math.max(img.width, img.height);
img.resize(Math.round(img.width * sc), Math.round(img.height * sc));
}
const gps = exif.GPSInfo || {};
// GPS tags normally sit under exif.GPSInfo, but some files / parser
// versions put them flat at the top level of exif — read from both.
function gpsTag(name) {
return gps[name] !== undefined ? gps[name] : exif[name];
}
// EXIF stores a coordinate as [degrees, minutes, seconds] (seconds may
// be missing) plus an N/S/E/W reference → one signed decimal number.
function toDecimal(dms, ref) {
if (!Array.isArray(dms) || dms.length < 2) return null;
let deg = dms[0] + dms[1] / 60 + (dms[2] || 0) / 3600;
if (ref === "S" || ref === "W") deg = -deg;
return deg;
}
const lat = toDecimal(gpsTag("GPSLatitude"), gpsTag("GPSLatitudeRef"));
const lon = toDecimal(gpsTag("GPSLongitude"), gpsTag("GPSLongitudeRef"));
// Draw a translucent caption bar at the bottom: a headline + small detail.
function drawCaption(headline, detail) {
const fs = Math.max(12, Math.round(img.height * 0.030));
const pad = Math.round(fs * 0.7);
const barH = Math.round(detail ? fs * 2 + pad * 2.4 : fs + pad * 2);
const y0 = img.height - barH;
const bar = Engine.createCanvas(img.width, barH);
bar.fill([0, 0, 0, BAR_OPACITY])
.drawPath(Engine.createPath().rect(0, 0, img.width, barH));
img.blendAt(bar.toImage(), px(0, y0), 1.0, Blend.Over);
bar.free();
img.drawText(headline, pad, Math.round(y0 + pad + fs * 0.85), {
size: fs, color: TEXT_COLOR, anchor: "start", font: "Inter",
});
if (detail) {
img.drawText(detail, pad, Math.round(y0 + pad + fs * 2.0), {
size: Math.round(fs * 0.72), color: TEXT_COLOR, anchor: "start", font: "Inter",
});
}
}
// Ask geocode.maps.co what's at this coordinate. Returns a tidy object;
// throws if the network call or JSON parse fails.
async function reverseGeocode(latitude, longitude, apiKey) {
const url = "https://geocode.maps.co/reverse"
+ `?lat=${latitude}&lon=${longitude}`
+ `&api_key=${encodeURIComponent(apiKey)}`;
const response = await Engine.fetch(url);
const data = await response.json();
const addr = data.address || {};
const place = addr.village || addr.town || addr.city || addr.hamlet || addr.municipality || "";
const region = addr.state || addr.county || "";
const country = addr.country || "";
return {
headline: [place, region, country].filter(Boolean).join(", ")
|| data.display_name || `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`,
detail: [addr.road, addr.postcode].filter(Boolean).join(" · "),
displayName: data.display_name,
address: addr,
};
}
// Decide what the caption should say, given what we have.
async function describeLocation() {
if (lat === null || lon === null) {
return { headline: "No GPS in EXIF", detail: "this image carries no location tags" };
}
const coords = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
if (!API_KEY) {
return { headline: coords, detail: "goto https://geocode.maps.co/ for api key" };
}
try {
const loc = await reverseGeocode(lat, lon, API_KEY);
img.setMeta("location", {
lat, lon, display_name: loc.displayName, address: loc.address,
source: "geocode.maps.co", resolvedAt: new Date().toISOString(),
});
Engine.log("Resolved:", loc.displayName);
return loc;
} catch (err) {
Engine.warn("geocode lookup failed:", err && err.message ? err.message : err);
return { headline: coords, detail: "geocode lookup failed" };
}
}
// Engine.fetch is async, so the work lives in an async function. The
// engine waits for this promise to settle before collecting OUTPUT.
async function main() {
const place = await describeLocation();
drawCaption(place.headline, place.detail);
img.save(OUTPUT);
}
main();
// © 2026 Michael Lechner · mlc OpticScript · https://mlcgo.eu · Elastic License 2.0