# Image Metadata & EXIF Extractor: GPS, Camera, Colors, Hash (`charlesmarkette/image-metadata-exif-extractor-gps-camera`) Actor

Read image metadata in bulk: EXIF camera and lens, exposure settings, date taken, GPS location with map link, size, color profile, dominant colors and perceptual hashes to find near-duplicates. Image URLs or a dataset column in. Failed images are free.

- **URL**: https://apify.com/charlesmarkette/image-metadata-exif-extractor-gps-camera.md
- **Developed by:** [Charles Markette](https://apify.com/charlesmarkette) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.50 / 1,000 analyzed images

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Image Metadata & EXIF Extractor: GPS, Camera, Colors, Hash

**Image Metadata & EXIF Extractor** reads the metadata of images in bulk and returns one row per image. Each row has the **camera, lens and exposure settings**, the **date taken** (with its timezone when the camera recorded one), the **GPS location** in decimal degrees with a Google Maps link, and the **author, copyright, title, description and keywords** from EXIF, XMP, IPTC or PNG text. It also gives **size, format, orientation, DPI and colour profile**, the **dominant colours**, a **SHA-256** and three **perceptual hashes** (aHash, pHash, dHash), and can **find near-duplicates**. Paste image URLs, or point it at any Apify dataset with image links, and the columns are added next to each item.

### Why this Actor

- **Readable fields, not a tag dump.** GPS degrees/minutes/seconds become signed decimal `lat` / `lon` (south and west are negative). Exposure becomes `"1/250"`, the flash code becomes `"Auto, fired"`, and the date becomes ISO 8601. If you want every tag, turn on **All EXIF tags (raw)**.
- **Honest dates.** EXIF dates are the camera's local time with no timezone. `dateTaken` is that local time. `dateTakenUtc` is only filled when the file records its offset (EXIF `OffsetTimeOriginal`, or an XMP date with a timezone). It is never guessed.
- **Descriptive metadata from every common place.** Author and copyright are read from EXIF `Artist` / `Copyright`, then XMP (`dc:creator`, `dc:rights`, `dc:title`, `dc:description`, `dc:subject`), then IPTC (By-line, Copyright Notice, Object Name, Caption, Keywords), then PNG text chunks. Keywords are merged from all of them.
- **Find duplicates and re-uploads.** `sha256` finds exact copies. `pHash` also matches copies that were resized, re-compressed or lightly edited. With **Find near-duplicates**, each row says which earlier image it looks like, and the run summary lists every group.
- **Privacy-minded.** Camera and lens serial numbers, the camera owner's name and the binary MakerNote are never output. **GPS location** can be switched off, which removes the coordinates everywhere, including the raw EXIF.
- **Safe with untrusted URLs.** Private, loopback, link-local and cloud-metadata addresses are refused, on every redirect hop too. Downloads are capped at 50 MB, and images are size-checked from their header before any pixels are decoded, so a "decompression bomb" can't exhaust memory.
- **One bad image never stops the run.** A missing, private, oversized or broken image gets one error row that explains the problem, and that row is free.

### Use cases

- **Photo libraries and DAM:** camera, lens, settings and date taken for every image, ready for a spreadsheet or database.
- **Copyright and licensing checks:** author, copyright and keywords embedded by photographers and agencies.
- **Location audits:** find which published images still carry GPS coordinates (`hasGps`), then strip them with the [Image Converter](https://apify.com/charlesmarkette/image-converter-resizer-webp-avif-jpg-png).
- **Duplicate and re-upload detection:** group a scraped catalogue's product photos by pHash to find the same picture under different URLs.
- **Colour tagging:** dominant and average colours for product images, to filter or sort by colour.
- **Image QA:** dimensions, megapixels, aspect ratio, DPI, colour profile, transparency and animation for a whole dataset of images.

### How to use it

1. Add **Image URLs** (one per line), and/or pick a **Dataset to enrich**. For datasets, set **Image URL fields** if your field isn't one of `imageUrl`, `imageUrls`, `image`, `images`, `thumbnailUrl`, `thumbnail`, `photo`, `src` or `url`. A field that holds a list of URLs gives one row per image.
2. Choose what to extract. GPS, colours and hashes are on by default. Raw EXIF and near-duplicate search are off.
3. Run it. Export the rows as CSV, Excel or JSON, or chain the run with an integration. The run summary is saved as `OUTPUT` in the key-value store.

**Input example**

```json
{
  "imageUrls": [
    "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/Canon_40D.jpg",
    "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/gps/DSCN0010.jpg"
  ],
  "includeGps": true,
  "includeColors": true,
  "includeHashes": true,
  "findDuplicates": true,
  "duplicateThreshold": 6
}
```

**Output example** (one row per image; the values are illustrative)

```json
{
  "url": "https://example.com/photos/IMG_4127.jpg",
  "finalUrl": "https://cdn.example.com/photos/IMG_4127.jpg",
  "statusCode": 200,
  "format": "jpeg",
  "mimeType": "image/jpeg",
  "width": 4000,
  "height": 6000,
  "megapixels": 24.0,
  "aspectRatio": 0.6667,
  "orientation": 6,
  "mode": "RGB",
  "hasAlpha": false,
  "animated": false,
  "frames": 1,
  "fileBytes": 8251334,
  "colorProfile": "sRGB IEC61966-2.1",
  "dpi": 72,
  "hasExif": true,
  "camera": {"make": "Canon", "model": "Canon EOS R6", "lens": "RF24-105mm F4 L IS USM"},
  "settings": {"exposureTime": "1/250", "exposureSeconds": 0.004, "fNumber": 8.0, "iso": 200, "focalLength": 50.0,
               "focalLength35mm": null, "flash": "Off, did not fire", "flashFired": false, "whiteBalance": "Auto"},
  "dateTaken": "2024-05-01T14:03:22+02:00",
  "dateTakenUtc": "2024-05-01T12:03:22Z",
  "timezoneOffset": "+02:00",
  "software": "Adobe Lightroom Classic 13.0",
  "author": "Jane Photographer",
  "copyright": "© 2024 Jane Photographer",
  "title": "Harbour at dawn",
  "description": "Fishing boats in the harbour",
  "keywords": ["harbour", "boats", "dawn"],
  "hasGps": true,
  "gps": {"lat": 52.3700944, "lon": 4.8838889, "altitude": 58.5,
          "mapsUrl": "https://www.google.com/maps?q=52.3700944,4.8838889", "timestampUtc": "2024-05-01T12:03:21Z"},
  "dominantColors": [{"hex": "#2f5d8a", "percent": 31.4}, {"hex": "#d9c7a0", "percent": 22.0},
                     {"hex": "#1c2a33", "percent": 18.7}, {"hex": "#8fa9bf", "percent": 15.2},
                     {"hex": "#5e4a36", "percent": 6.1}],
  "averageColor": "#6b7a84",
  "hashes": {"sha256": "9f2b…", "aHash": "ffc3810000183cff", "pHash": "b696446969490fbd", "dHash": "042c6c9c1f638801"},
  "duplicateOf": null,
  "duplicateDistance": null,
  "rawExif": null,
  "error": null,
  "analyzedAt": "2026-10-21T12:00:00Z"
}
```

- `width` and `height` are the size **as displayed**: the EXIF orientation is applied, so a portrait phone photo stored sideways (orientation 6 or 8) is reported as portrait. `megapixels` is the stored pixel count.
- `camera`, `settings` and `gps` are `null` when the file has none of those fields. Other fields are `null` when the file doesn't contain them.
- `hasGps` says whether the file contains a GPS position, even when **GPS location** is off.
- `rawExif` (with **All EXIF tags**) is an object of tag name → value. Rationals become numbers, and binary values become `"<N bytes>"`.
- A failed image has `error` set and the metadata fields `null`. When you enrich a dataset with **Keep the item's original fields** on, the item's own fields come first in each row.

### Near-duplicates

With **Find near-duplicates** on, each image's pHash is compared with every image analyzed before it (in this run, and in the earlier part of a resumed run):

- **Per row:** `duplicateOf` is the URL of the **first earlier image** within the **Near-duplicate threshold**, and `duplicateDistance` is how many of the 64 bits differ (0 = the same picture to the hash). The first image of a group has `duplicateOf: null`.
- **Groups, in the run summary:** `OUTPUT` → `duplicateGroups` lists every group as `{"group": 1, "count": 3, "images": [{"url": "…", "index": 0}, …]}`, where `index` is the row's position in the dataset (0-based). Groups chain: if A matches B and B matches C, all three are in one group even if A and C are further apart. Up to 20,000 images are listed (`duplicateGroupsTruncated` says when more were found).
- **Threshold:** 0 matches visually identical images. 6 (the default) also catches resized, re-compressed and slightly edited copies. Values above about 10 start to match different photos of similar scenes. Flat, single-colour images all hash alike and group together.

Rows can't be changed after they are stored, so the group number isn't a column. Use `duplicateOf` in the rows, or `duplicateGroups` in `OUTPUT`.

### How much does it cost?

Pay per event, Apify platform usage included:

- **$0.005 per analyzed image** (`image-analyzed`): each image that was downloaded and decoded, and got its metadata row.
- **$0.005 run start per GB of Actor memory** (minimum one; at the default 1 GB that's $0.005 a run). The run start applies to every run, even one where every image fails.

**Apify Store discounts:** the price above is for Apify's Free plan. Paid plans pay less per analyzed image: $0.0045 on Starter, $0.004 on Scale, $0.0035 on Business. The "from" price at the top of the Store page is the Business price.

**Free:** images that fail (download errors, HTTP errors such as 404 or 403, blocked private addresses, invalid URLs, files over 50 MB, pages or files that aren't images, images over the size limit, damaged files) and repeats skipped by *Analyze each URL once*. Near-duplicate search, raw EXIF, colours and hashes cost nothing extra.

**Worked examples:**

*Examples use the Free-plan rate.*

- 1,000 product photos at the default 1 GB: 1,000 × $0.005 + $0.005 run start = **$5.005**.
- 200 image links where 20 return 404: 180 × $0.005 + $0.005 = **$0.905**. The 20 failed rows are free.
- A daily run on 50 new images: 50 × $0.005 + $0.005 = $0.255 per run, about **$7.65 per month** (30 runs).
- 2,000 large camera photos with the memory raised to 4 GB: 2,000 × $0.005 + 4 × $0.005 = **$10.02**.

If you set a maximum cost per run, the Actor stops cleanly at that limit: images beyond it are neither stored nor charged.

### Privacy and lawful use

GPS coordinates, dates, camera details and author names in image metadata can be **personal data**, for example under the GDPR in the EU and UK, and under similar laws elsewhere. A photo's location can reveal where someone lives or was at a given time. You are responsible for having a lawful basis to collect and use this data, and for using it in line with the law and the image owners' rights. Turn off **GPS location** if you don't need it. Camera and lens serial numbers and the camera owner's name are never output. To publish images without their metadata, strip it with the [Image Converter](https://apify.com/charlesmarkette/image-converter-resizer-webp-avif-jpg-png).

### FAQ and limits

#### Why are camera, date and GPS empty for my image?

The metadata isn't in the file you linked. Social networks (Instagram, Facebook, X, WhatsApp), most image CDNs, and many CMSs remove EXIF and GPS when they resize or re-encode an image. Phones also often leave out the location when you share a photo. Link the original file where you can. The Actor's request doesn't list WebP or AVIF among the formats it accepts, so CDNs that pick a format from the request are less likely to send a re-encoded copy.

#### Which formats are supported?

JPEG, PNG (and APNG), WebP, GIF, AVIF, TIFF and BMP. **HEIC / HEIF (iPhone photos) isn't supported**: the decoder needed for it isn't included, for licensing reasons. Convert HEIC to JPEG first (iPhones can share as JPEG, *Most Compatible*). RAW camera files, SVG and PDF aren't supported either. For animations and multi-frame files, `frames` counts the frames and the colours and hashes come from the first frame.

#### What isn't read?

Vendor MakerNotes (shutter count, some lens names that only appear there), and XMP fields beyond the common descriptive ones listed above. **All EXIF tags** shows every standard EXIF tag except serial numbers, the owner name and the MakerNote.

#### How do the hashes compare with other tools?

aHash, pHash and dHash use the same definitions as the popular Python `imagehash` library (8×8 grid, 64 bits, hex with the first pixel as the most significant bit). They are computed from a reduced copy of the image with the EXIF orientation applied, so values can differ from other tools by a bit or two. Compare hashes with a Hamming distance, not with equality. `sha256` is of the downloaded file's bytes.

#### Is `0,0` a location?

Many devices write 0,0 when they have no GPS fix, so 0,0 (and missing or 0/0 values) is treated as "no position".

#### Limits

- Download size: 50 MB per image. The file is downloaded in full (the pixels are decoded to check the image and compute colours and hashes).
- Image size: JPEG up to 150 megapixels (decoded at reduced scale); other formats up to 60 megapixels. Larger images get a free error row.
- No proxy, cookies or browser: images behind sign-ins, bot protection or hotlink protection can fail with HTTP 403.
- Google Drive and Dropbox share links to a single file are turned into download links. Folder links aren't supported.
- If the platform restarts a run, already stored rows are skipped and the run continues after them. Near-duplicate search reloads the earlier rows' hashes.

### Related Actors

| Actor | Use it to |
|---|---|
| [Image Converter & Resizer](https://apify.com/charlesmarkette/image-converter-resizer-webp-avif-jpg-png) | Strip EXIF and GPS before publishing, or convert and resize the images you analyzed. |
| [Dataset Filter & Transform](https://apify.com/charlesmarkette/dataset-filter-transform-rename-flatten-fields) | Keep only the rows you need (for example `hasGps: true`), or flatten `camera` and `settings` into columns. |
| [Merge & Deduplicate Datasets](https://apify.com/charlesmarkette/merge-deduplicate-datasets-only-new-items) | Keep only new image links between runs before analyzing them, or deduplicate rows by `hashes.sha256`. |
| [Google Sheets Import & Export](https://apify.com/charlesmarkette/google-sheets-sync) | Put the metadata rows into a Google Sheet. |

Image decoding: [Pillow](https://python-pillow.org/). Hash definitions: [imagehash](https://github.com/JohannesBuchner/imagehash) (not a dependency; implemented here).

# Actor input Schema

## `imageUrls` (type: `array`):

One per line. Direct links to JPEG, PNG, WebP, GIF, AVIF, TIFF or BMP images (HEIC isn't supported). Google Drive and Dropbox share links to a single file work too. The format is detected from the file itself, so links without an extension are fine. Use the original file's address: social networks and many CDNs remove EXIF when they resize images.

## `datasetId` (type: `string`):

Optional. A dataset from another run with image links (product photos, listing pictures, profile images…). For each item, the first field below that holds an image URL is analyzed and the metadata columns are added next to the item's fields. A field holding a list of URLs gives one row per image. Use {{resource.defaultDatasetId}} in an integration.

## `urlFields` (type: `array`):

Item fields to take the image URL from, in order of preference. Dotted paths work (product.image). Default: imageUrl, imageUrls, image, images, thumbnailUrl, thumbnail, photo, src, url.

## `keepItemFields` (type: `boolean`):

On: each output row is the original item plus the metadata columns (metadata columns win if a name clashes). Off: only the metadata columns.

## `includeGps` (type: `boolean`):

On: latitude, longitude and altitude in decimal degrees and metres, plus a Google Maps link, when the photo has them. Off: no coordinates anywhere in the output (also left out of the raw EXIF); the hasGps column still says whether the file contains a location. Location data can be personal data: see the README.

## `includeColors` (type: `boolean`):

The 5 most common colours (hex and share of the picture) and the average colour.

## `includeHashes` (type: `boolean`):

SHA-256 of the file (exact copies) and the 64-bit perceptual hashes aHash, pHash and dHash (similar-looking images have hashes a few bits apart).

## `includeRawExif` (type: `boolean`):

Adds rawExif: every EXIF tag by name. Serial numbers, the camera owner's name and the binary MakerNote are never included.

## `findDuplicates` (type: `boolean`):

Compares each image's pHash with every image before it. Rows get duplicateOf (the first earlier image that looks the same) and duplicateDistance, and the run summary (OUTPUT) lists all groups of near-duplicates. Turns on Hashes.

## `duplicateThreshold` (type: `integer`):

Maximum number of differing pHash bits (0–16) for two images to count as near-duplicates. 0 = visually identical; 6 (default) catches resized, re-compressed and slightly edited copies; higher values also match different photos of a similar scene.

## `dedupe` (type: `boolean`):

Skip repeats of the same image URL (no row, no charge).

## `concurrency` (type: `integer`):

How many images are downloaded and analyzed at once (1–16).

## `maxResults` (type: `integer`):

Optional cap on output rows (one row per image URL, including free error rows).

## Actor input object example

```json
{
  "imageUrls": [
    "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/Canon_40D.jpg",
    "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/gps/DSCN0010.jpg"
  ],
  "keepItemFields": true,
  "includeGps": true,
  "includeColors": true,
  "includeHashes": true,
  "includeRawExif": false,
  "findDuplicates": false,
  "duplicateThreshold": 6,
  "dedupe": true,
  "concurrency": 4
}
```

# Actor output Schema

## `items` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "imageUrls": [
        "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/Canon_40D.jpg",
        "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/gps/DSCN0010.jpg"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("charlesmarkette/image-metadata-exif-extractor-gps-camera").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "imageUrls": [
        "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/Canon_40D.jpg",
        "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/gps/DSCN0010.jpg",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("charlesmarkette/image-metadata-exif-extractor-gps-camera").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "imageUrls": [
    "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/Canon_40D.jpg",
    "https://raw.githubusercontent.com/ianare/exif-samples/master/jpg/gps/DSCN0010.jpg"
  ]
}' |
apify call charlesmarkette/image-metadata-exif-extractor-gps-camera --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,charlesmarkette/image-metadata-exif-extractor-gps-camera"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/XZ5sNMqdg9tSXhEHX/builds/YOedrSxrpa7qFPFQN/openapi.json
