EXIF-GPS zur Bildunterschrift — Reverse-Geocoding

Liest die GPS-Position aus dem EXIF des Bildes und schreibt sie als Beschriftungsleiste hinein. Mit einem Schlüssel für geocode.maps.co (OpenStreetMap-Daten) löst es den Ortsnamen auf; ohne zeigt es die Koordinaten — so wie das gerenderte Beispiel hier.

INPUT
INPUT — EXIF-GPS zur Bildunterschrift — Reverse-Geocoding
OUTPUT
OUTPUT — EXIF-GPS zur Bildunterschrift — Reverse-Geocoding
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