Last active
July 14, 2025 11:59
-
-
Save Chalkin/12c3c9fd7320cc1355ceb9eb9ee718bc to your computer and use it in GitHub Desktop.
Embedding meta data to Photos use after Google Photos Takeout
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Google Photos EXIF Metadata Embedding Tool - Use it after using Google Photos Takeout | |
This command line python script processes image files (JPG, JPEG, HEIC) and their corresponding | |
Google Photos JSON metadata files to embed GPS coordinates and photo taken | |
time into the image's EXIF data. | |
Features: | |
- Reads 'photoTakenTime', 'geoDataExif', and 'geoData' from Google Photos JSONs. | |
- Supports various Google Photos JSON naming patterns (e.g., 'image.jpg.json', 'image(1).jpg.json'). | |
- Uses 'piexif' for JPG/JPEG files. | |
- Uses 'ExifTool' (external utility) for HEIC files. If ExifTool detects a HEIC | |
is actually a JPEG, it renames both the image (.heic -> .jpg) and its JSON | |
before processing with piexif for consistency. | |
Usage on macOS: | |
1. Save this script as e.g., 'google_photos_exif_tool.py'. | |
2. Open Terminal. | |
3. (Recommended) Create & activate a Python virtual environment: | |
```bash | |
python3 -m venv venv | |
source venv/bin/activate | |
``` | |
4. Install Python dependencies: | |
```bash | |
pip install piexif | |
``` | |
5. Install ExifTool (requires Homebrew: https://brew.sh): | |
```bash | |
brew install exiftool | |
``` | |
6. Run the script: | |
```bash | |
python3 google_photos_exif_tool.py "/path/to/your/photos/folder" | |
# Or, to process the current directory: | |
python3 google_photos_exif_tool.py | |
``` | |
7. Deactivate virtual environment when done: | |
```bash | |
deactivate | |
``` | |
IMPORTANT: Always back up your original photos and JSONs before running any script that modifies them! | |
""" | |
import os | |
import json | |
import piexif | |
from datetime import datetime | |
import sys | |
import re | |
import subprocess | |
# Define the common JSON suffixes for standard pattern (full image filename + suffix) | |
STANDARD_JSON_SUFFIXES = [ | |
".supplemental-metadata.json", | |
".supp.json", | |
".suppl.json" | |
] | |
# Regex to detect (N) in image filenames (e.g., "IMG_1465(1).JPG") | |
# Captures the base name before (N) and the number N | |
IMAGE_DUPLICATE_PATTERN = re.compile(r"^(.*)\((\d+)\)$") | |
def embed_gps_data(image_path, json_path): | |
""" | |
Embeds GPS and photo taken time data from a specific Google Photos JSON file | |
into a JPEG/HEIC image. Uses ExifTool for HEIC, piexif for JPG. | |
Includes extensive debug prints. If a .HEIC file is detected as a JPEG by ExifTool, | |
it attempts to rename both the image and its JSON to .JPG before processing with piexif. | |
Args: | |
image_path (str): The full path to the image file. | |
json_path (str): The full path to the corresponding JSON file. | |
Returns: | |
tuple: (bool, bool) | |
First bool: True if image was successfully processed, False otherwise. | |
Second bool: True if the image file was renamed (HEIC to JPG), False otherwise. | |
""" | |
print(f"\n--- DEBUG: Processing Image: {os.path.basename(image_path)} ---") | |
print(f"DEBUG: Associated JSON: {os.path.basename(json_path)}") | |
# Initialize return status | |
processed_successfully = False | |
file_was_renamed = False | |
# 1. Load JSON metadata | |
try: | |
with open(json_path, 'r', encoding='utf-8') as f: | |
metadata = json.load(f) | |
print("DEBUG: JSON file loaded successfully.") | |
except (FileNotFoundError, json.JSONDecodeError) as e: | |
print(f"ERROR: Could not load or decode JSON from {json_path}: {e}") | |
return (False, False) # Cannot proceed without valid JSON | |
except Exception as e: | |
print(f"AN UNEXPECTED ERROR OCCURRED while loading JSON from {json_path}: {e}") | |
return (False, False) | |
# 2. Extract Photo Taken Time | |
photo_taken_time_data = metadata.get('photoTakenTime', {}) | |
photo_taken_timestamp = photo_taken_time_data.get('timestamp') | |
exif_datetime = None | |
if photo_taken_timestamp: | |
try: | |
dt_object = datetime.fromtimestamp(int(photo_taken_timestamp)) | |
exif_datetime = dt_object.strftime("%Y:%m:%d %H:%M:%S") | |
print(f"DEBUG: Extracted photoTakenTime timestamp: {photo_taken_timestamp} -> {exif_datetime}") | |
except ValueError as e: | |
print(f"DEBUG ERROR: Could not convert photoTakenTime timestamp '{photo_taken_timestamp}': {e}") | |
else: | |
print("DEBUG: 'photoTakenTime' or 'timestamp' not found in JSON.") | |
# 3. Extract GPS Data - Try geoDataExif first, then fallback to geoData | |
latitude = None | |
longitude = None | |
altitude = None | |
geo_data_exif = metadata.get('geoDataExif', {}) | |
if geo_data_exif: | |
temp_lat_exif = geo_data_exif.get('latitude') | |
temp_lon_exif = geo_data_exif.get('longitude') | |
temp_alt_exif = geo_data_exif.get('altitude') | |
if temp_lat_exif is not None and temp_lon_exif is not None and (temp_lat_exif != 0.0 or temp_lon_exif != 0.0): | |
latitude = temp_lat_exif | |
longitude = temp_lon_exif | |
altitude = temp_alt_exif | |
print("DEBUG: Using GPS data from 'geoDataExif' section (original EXIF).") | |
print(f"DEBUG: geoDataExif: Latitude={latitude}, Longitude={longitude}, Altitude={altitude}") | |
else: | |
print("DEBUG: 'geoDataExif' section found, but coordinates are missing or 0.0.") | |
else: | |
print("DEBUG: 'geoDataExif' section not found in JSON.") | |
if latitude is None or longitude is None or (latitude == 0.0 and longitude == 0.0): | |
geo_data = metadata.get('geoData', {}) | |
temp_lat_geo = geo_data.get('latitude') | |
temp_lon_geo = geo_data.get('longitude') | |
temp_alt_geo = geo_data.get('altitude') | |
if temp_lat_geo is not None and temp_lon_geo is not None and (temp_lat_geo != 0.0 or temp_lon_geo != 0.0): | |
latitude = temp_lat_geo | |
longitude = temp_lon_geo | |
altitude = temp_alt_geo | |
print("DEBUG: Using GPS data from 'geoData' section (Google Photos inferred/manual).") | |
print(f"DEBUG: geoData: Latitude={latitude}, Longitude={longitude}, Altitude={altitude}") | |
else: | |
print("DEBUG: 'geoData' section found, but coordinates are missing or 0.0. No valid GPS data found from either source.") | |
print(f"DEBUG: Final GPS values for embedding: Latitude={latitude}, Longitude={longitude}, Altitude={altitude}") | |
# Determine if it's a HEIC file by extension | |
is_heic_extension = image_path.lower().endswith(('.heic')) | |
# Flag to indicate if ExifTool successfully handled the file | |
exiftool_success = False | |
if is_heic_extension: | |
print(f"DEBUG: Attempting to use ExifTool for metadata embedding (based on .HEIC extension).") | |
exiftool_cmd = ["exiftool", "-overwrite_original"] | |
if exif_datetime: | |
exiftool_cmd.append(f"-DateTimeOriginal={exif_datetime}") | |
exiftool_cmd.append(f"-CreateDate={exif_datetime}") | |
exiftool_cmd.append(f"-ModifyDate={exif_datetime}") | |
else: | |
print("INFO: No photoTakenTime available for HEIC, skipping date embedding via ExifTool.") | |
if latitude is not None and longitude is not None and (latitude != 0.0 or longitude != 0.0): | |
exiftool_cmd.append(f"-GPSLatitude={latitude}") | |
exiftool_cmd.append(f"-GPSLongitude={longitude}") | |
if altitude is not None: | |
exiftool_cmd.append(f"-GPSAltitude={altitude}") | |
exiftool_cmd.append("-GPSAltitudeRef=Above Sea Level") | |
print("DEBUG: GPS data is non-zero, proceeding to embed via ExifTool.") | |
else: | |
print("INFO: No valid GPS data available for HEIC, skipping GPS embedding via ExifTool.") | |
exiftool_cmd.append(image_path) | |
print(f"DEBUG: ExifTool command: {' '.join(exiftool_cmd)}") | |
try: | |
if len(exiftool_cmd) > 2: | |
result = subprocess.run(exiftool_cmd, capture_output=True, text=True, check=True) | |
# Check ExifTool's stderr for the specific "Not a valid HEIC" error | |
if "Not a valid HEIC (looks more like a JPEG)" in result.stderr: | |
print(f"WARNING: ExifTool reported '{os.path.basename(image_path)}' is not a valid HEIC (looks like a JPEG).") | |
# --- START RENAMING LOGIC --- | |
old_image_path = image_path | |
new_image_path = os.path.splitext(old_image_path)[0] + ".jpg" | |
old_json_path = json_path | |
json_basename = os.path.basename(old_json_path) | |
# FIX: Replace both .HEIC. and .heic. with .jpg. (lowercase) | |
new_json_basename = json_basename.replace(".HEIC.", ".jpg.", 1).replace(".heic.", ".jpg.", 1) | |
new_json_path = os.path.join(os.path.dirname(old_json_path), new_json_basename) | |
print(f"INFO: Renaming '{os.path.basename(old_image_path)}' to '{os.path.basename(new_image_path)}'.") | |
print(f"INFO: Renaming associated JSON '{os.path.basename(old_json_path)}' to '{os.path.basename(new_json_path)}'.") | |
try: | |
os.rename(old_image_path, new_image_path) | |
os.rename(old_json_path, new_json_path) | |
print("SUCCESS: Files renamed.") | |
file_was_renamed = True # Set flag here | |
# Update variables for subsequent | |
import os | |
import json | |
import piexif | |
from datetime import datetime | |
import sys | |
import re | |
import subprocess | |
# Define the common JSON suffixes for standard pattern (full image filename + suffix) | |
STANDARD_JSON_SUFFIXES = [ | |
".supplemental-metadata.json", | |
".supp.json", | |
".suppl.json" | |
] | |
# Regex to detect (N) in image filenames (e.g., "IMG_1465(1).JPG") | |
# Captures the base name before (N) and the number N | |
IMAGE_DUPLICATE_PATTERN = re.compile(r"^(.*)\((\d+)\)$") | |
def embed_gps_data(image_path, json_path): | |
""" | |
Embeds GPS and photo taken time data from a specific Google Photos JSON file | |
into a JPEG/HEIC image. Uses ExifTool for HEIC, piexif for JPG. | |
Includes extensive debug prints. If a .HEIC file is detected as a JPEG by ExifTool, | |
it attempts to rename both the image and its JSON to .JPG before processing with piexif. | |
Args: | |
image_path (str): The full path to the image file. | |
json_path (str): The full path to the corresponding JSON file. | |
Returns: | |
tuple: (bool, bool) | |
First bool: True if image was successfully processed, False otherwise. | |
Second bool: True if the image file was renamed (HEIC to JPG), False otherwise. | |
""" | |
print(f"\n--- DEBUG: Processing Image: {os.path.basename(image_path)} ---") | |
print(f"DEBUG: Associated JSON: {os.path.basename(json_path)}") | |
# Initialize return status | |
processed_successfully = False | |
file_was_renamed = False | |
# 1. Load JSON metadata | |
try: | |
with open(json_path, 'r', encoding='utf-8') as f: | |
metadata = json.load(f) | |
print("DEBUG: JSON file loaded successfully.") | |
except (FileNotFoundError, json.JSONDecodeError) as e: | |
print(f"ERROR: Could not load or decode JSON from {json_path}: {e}") | |
return (False, False) # Cannot proceed without valid JSON | |
except Exception as e: | |
print(f"AN UNEXPECTED ERROR OCCURRED while loading JSON from {json_path}: {e}") | |
return (False, False) | |
# 2. Extract Photo Taken Time | |
photo_taken_time_data = metadata.get('photoTakenTime', {}) | |
photo_taken_timestamp = photo_taken_time_data.get('timestamp') | |
exif_datetime = None | |
if photo_taken_timestamp: | |
try: | |
dt_object = datetime.fromtimestamp(int(photo_taken_timestamp)) | |
exif_datetime = dt_object.strftime("%Y:%m:%d %H:%M:%S") | |
print(f"DEBUG: Extracted photoTakenTime timestamp: {photo_taken_timestamp} -> {exif_datetime}") | |
except ValueError as e: | |
print(f"DEBUG ERROR: Could not convert photoTakenTime timestamp '{photo_taken_timestamp}': {e}") | |
else: | |
print("DEBUG: 'photoTakenTime' or 'timestamp' not found in JSON.") | |
# 3. Extract GPS Data - Try geoDataExif first, then fallback to geoData | |
latitude = None | |
longitude = None | |
altitude = None | |
geo_data_exif = metadata.get('geoDataExif', {}) | |
if geo_data_exif: | |
temp_lat_exif = geo_data_exif.get('latitude') | |
temp_lon_exif = geo_data_exif.get('longitude') | |
temp_alt_exif = geo_data_exif.get('altitude') | |
if temp_lat_exif is not None and temp_lon_exif is not None and (temp_lat_exif != 0.0 or temp_lon_exif != 0.0): | |
latitude = temp_lat_exif | |
longitude = temp_lon_exif | |
altitude = temp_alt_exif | |
print("DEBUG: Using GPS data from 'geoDataExif' section (original EXIF).") | |
print(f"DEBUG: geoDataExif: Latitude={latitude}, Longitude={longitude}, Altitude={altitude}") | |
else: | |
print("DEBUG: 'geoDataExif' section found, but coordinates are missing or 0.0.") | |
else: | |
print("DEBUG: 'geoDataExif' section not found in JSON.") | |
if latitude is None or longitude is None or (latitude == 0.0 and longitude == 0.0): | |
geo_data = metadata.get('geoData', {}) | |
temp_lat_geo = geo_data.get('latitude') | |
temp_lon_geo = geo_data.get('longitude') | |
temp_alt_geo = geo_data.get('altitude') | |
if temp_lat_geo is not None and temp_lon_geo is not None and (temp_lat_geo != 0.0 or temp_lon_geo != 0.0): | |
latitude = temp_lat_geo | |
longitude = temp_lon_geo | |
altitude = temp_alt_geo | |
print("DEBUG: Using GPS data from 'geoData' section (Google Photos inferred/manual).") | |
print(f"DEBUG: geoData: Latitude={latitude}, Longitude={longitude}, Altitude={altitude}") | |
else: | |
print("DEBUG: 'geoData' section found, but coordinates are missing or 0.0. No valid GPS data found from either source.") | |
print(f"DEBUG: Final GPS values for embedding: Latitude={latitude}, Longitude={longitude}, Altitude={altitude}") | |
# Determine if it's a HEIC file by extension | |
is_heic_extension = image_path.lower().endswith(('.heic')) | |
# Flag to indicate if ExifTool successfully handled the file | |
exiftool_success = False | |
if is_heic_extension: | |
print(f"DEBUG: Attempting to use ExifTool for metadata embedding (based on .HEIC extension).") | |
exiftool_cmd = ["exiftool", "-overwrite_original"] | |
if exif_datetime: | |
exiftool_cmd.append(f"-DateTimeOriginal={exif_datetime}") | |
exiftool_cmd.append(f"-CreateDate={exif_datetime}") | |
exiftool_cmd.append(f"-ModifyDate={exif_datetime}") | |
else: | |
print("INFO: No photoTakenTime available for HEIC, skipping date embedding via ExifTool.") | |
if latitude is not None and longitude is not None and (latitude != 0.0 or longitude != 0.0): | |
exiftool_cmd.append(f"-GPSLatitude={latitude}") | |
exiftool_cmd.append(f"-GPSLongitude={longitude}") | |
if altitude is not None: | |
exiftool_cmd.append(f"-GPSAltitude={altitude}") | |
exiftool_cmd.append("-GPSAltitudeRef=Above Sea Level") | |
print("DEBUG: GPS data is non-zero, proceeding to embed via ExifTool.") | |
else: | |
print("INFO: No valid GPS data available for HEIC, skipping GPS embedding via ExifTool.") | |
exiftool_cmd.append(image_path) | |
print(f"DEBUG: ExifTool command: {' '.join(exiftool_cmd)}") | |
try: | |
if len(exiftool_cmd) > 2: | |
result = subprocess.run(exiftool_cmd, capture_output=True, text=True, check=True) | |
# Check ExifTool's stderr for the specific "Not a valid HEIC" error | |
if "Not a valid HEIC (looks more like a JPEG)" in result.stderr: | |
print(f"WARNING: ExifTool reported '{os.path.basename(image_path)}' is not a valid HEIC (looks like a JPEG).") | |
# --- START RENAMING LOGIC --- | |
old_image_path = image_path | |
new_image_path = os.path.splitext(old_image_path)[0] + ".jpg" | |
old_json_path = json_path | |
json_basename = os.path.basename(old_json_path) | |
# FIX: Replace both .HEIC. and .heic. with .jpg. (lowercase) | |
new_json_basename = json_basename.replace(".HEIC.", ".jpg.", 1).replace(".heic.", ".jpg.", 1) | |
new_json_path = os.path.join(os.path.dirname(old_json_path), new_json_basename) | |
print(f"INFO: Renaming '{os.path.basename(old_image_path)}' to '{os.path.basename(new_image_path)}'.") | |
print(f"INFO: Renaming associated JSON '{os.path.basename(old_json_path)}' to '{os.path.basename(new_json_path)}'.") | |
try: | |
os.rename(old_image_path, new_image_path) | |
os.rename(old_json_path, new_json_path) | |
print("SUCCESS: Files renamed.") | |
file_was_renamed = True # Set flag here | |
# Update variables for subsequent processing in this function call | |
image_path = new_image_path | |
json_path = new_json_path | |
is_heic_extension = False # CRITICAL: Flag it as a non-HEIC so piexif is used | |
print(f"DEBUG: Proceeding to process with piexif on renamed file.") | |
except OSError as rename_error: | |
print(f"ERROR: Failed to rename files. Please check permissions or if files are in use. Error: {rename_error}") | |
print(f"--- DEBUG: Finished processing {os.path.basename(old_image_path)} ---") | |
return (False, False) # Return failure status | |
# --- END RENAMING LOGIC --- | |
else: | |
# ExifTool handled a *true* HEIC correctly | |
print(f"SUCCESS: ExifTool output for {os.path.basename(image_path)}:\n{result.stdout}") | |
if result.stderr: | |
print(f"EXIFTOOL STDERR: {result.stderr}") | |
exiftool_success = True | |
else: | |
# If no commands were appended (no date/gps) but it's a HEIC, still consider success. | |
print(f"INFO: No date or GPS data to embed for HEIC file '{os.path.basename(image_path)}' via ExifTool.") | |
exiftool_success = True | |
except FileNotFoundError: | |
print(f"ERROR: ExifTool not found. Please install ExifTool (e.g., 'brew install exiftool' on macOS, 'sudo apt-get install libimage-exiftool-perl' on Debian/Ubuntu, or download from exiftool.org).") | |
print(f"DEBUG: Attempting to process with piexif instead (ExifTool not found). Note: File extension will remain .HEIC if ExifTool is not present to identify it as JPEG.") | |
except subprocess.CalledProcessError as e: | |
print(f"ERROR: ExifTool failed for {os.path.basename(image_path)}. Return code: {e.returncode}") | |
print(f"STDOUT: {e.stdout}") | |
print(f"STDERR: {e.stderr}") | |
if "Not a valid HEIC (looks more like a JPEG)" in e.stderr: | |
print(f"WARNING: ExifTool reported '{os.path.basename(image_path)}' is not a valid HEIC (looks like a JPEG).") | |
# --- DUPLICATE RENAMING LOGIC FOR CalledProcessError (necessary for comprehensive error handling) --- | |
old_image_path = image_path | |
new_image_path = os.path.splitext(old_image_path)[0] + ".jpg" | |
old_json_path = json_path | |
json_basename = os.path.basename(old_json_path) | |
# FIX: Replace both .HEIC. and .heic. with .jpg. (lowercase) | |
new_json_basename = json_basename.replace(".HEIC.", ".jpg.", 1).replace(".heic.", ".jpg.", 1) | |
new_json_path = os.path.join(os.path.dirname(old_json_path), new_json_basename) | |
print(f"INFO: Renaming '{os.path.basename(old_image_path)}' to '{os.path.basename(new_image_path)}'.") | |
print(f"INFO: Renaming associated JSON '{os.path.basename(old_json_path)}' to '{os.path.basename(new_json_path)}'.") | |
try: | |
os.rename(old_image_path, new_image_path) | |
os.rename(old_json_path, new_json_path) | |
print("SUCCESS: Files renamed.") | |
file_was_renamed = True # Set flag here | |
image_path = new_image_path | |
json_path = new_json_path | |
is_heic_extension = False # CRITICAL: Flag it as a non-HEIC so piexif is used | |
print(f"DEBUG: Proceeding to process with piexif on renamed file.") | |
except OSError as rename_error: | |
print(f"ERROR: Failed to rename files. Please check permissions or if files are in use. Error: {rename_error}") | |
print(f"--- DEBUG: Finished processing {os.path.basename(old_image_path)} ---") | |
return (False, False) | |
# --- END DUPLICATE RENAMING LOGIC --- | |
else: | |
print(f"ERROR: ExifTool failed for a non-HEIC validation reason. Cannot proceed with this file via ExifTool.") | |
print(f"--- DEBUG: Finished processing {os.path.basename(image_path)} ---") | |
return (False, False) | |
except Exception as e: | |
print(f"AN UNEXPECTED ERROR OCCURRED during ExifTool execution for {os.path.basename(image_path)}: {e}") | |
print(f"--- DEBUG: Finished processing {os.path.basename(image_path)} ---") | |
return (False, False) | |
# This block is entered if exiftool_success is False (either it's a JPG/JPEG initially, or | |
# it was a mislabeled HEIC that got renamed, or ExifTool wasn't found). | |
if not exiftool_success: | |
print(f"INFO: Now processing '{os.path.basename(image_path)}' using piexif.") | |
# Helper functions for piexif (DMS conversion) | |
def decimal_to_dms(decimal_deg): | |
is_positive = decimal_deg >= 0 | |
abs_decimal_deg = abs(decimal_deg) | |
degrees = int(abs_decimal_deg) | |
minutes = int((abs_decimal_deg - degrees) * 60) | |
seconds = int(((abs_decimal_deg - degrees) * 60 - minutes) * 60 * 100) # Multiply by 100 for fraction | |
return ((degrees, 1), (minutes, 1), (seconds, 100)), 'N' if is_positive else 'S' | |
def decimal_to_dms_lon(decimal_deg): | |
is_positive = decimal_deg >= 0 | |
abs_decimal_deg = abs(decimal_deg) | |
degrees = int(abs_decimal_deg) | |
minutes = int((abs_decimal_deg - degrees) * 60) | |
seconds = int(((abs_decimal_deg - degrees) * 60 - minutes) * 60 * 100) # Multiply by 100 for fraction | |
return ((degrees, 1), (minutes, 1), (seconds, 100)), 'E' if is_positive else 'W' | |
# Read existing EXIF data or create new | |
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}, "thumbnail": None} # Initialize directly | |
try: | |
loaded_exif_dict = piexif.load(image_path) | |
exif_dict.update(loaded_exif_dict) | |
print("DEBUG: Existing EXIF data loaded and merged.") | |
except piexif.InvalidImageDataError: | |
print("DEBUG: Image has invalid/no EXIF data (as expected by piexif). Initializing new EXIF dictionary.") | |
except Exception as e: | |
print(f"ERROR: Could not load EXIF from {os.path.basename(image_path)}. This might indicate a corrupted JPG or unsupported format by piexif. Error: {e}") | |
print(f"--- DEBUG: Finished processing {os.path.basename(image_path)} ---") | |
return (False, False) | |
# Update Photo Taken Time if available | |
if exif_datetime: | |
exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = exif_datetime.encode("utf-8") | |
exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = exif_datetime.encode("utf-8") | |
exif_dict["0th"][piexif.ImageIFD.DateTime] = exif_datetime.encode("utf-8") | |
print(f"DEBUG: DateTimeOriginal, DateTimeDigitized, DateTime set to: {exif_datetime}") | |
else: | |
print("DEBUG: exif_datetime is None. Skipping date/time embedding.") | |
# Update GPS data if available | |
gps_ifd = {} | |
if latitude is not None and longitude is not None and (latitude != 0.0 or longitude != 0.0): | |
print("DEBUG: GPS data is non-zero, proceeding to embed.") | |
lat_dms, lat_ref = decimal_to_dms(latitude) | |
lon_dms, lon_ref = decimal_to_dms_lon(longitude) | |
gps_ifd[piexif.GPSIFD.GPSVersionID] = (2, 0, 0, 0) | |
gps_ifd[piexif.GPSIFD.GPSLatitudeRef] = lat_ref | |
gps_ifd[piexif.GPSIFD.GPSLatitude] = lat_dms | |
gps_ifd[piexif.GPSIFD.GPSLongitudeRef] = lon_ref | |
gps_ifd[piexif.GPSIFD.GPSLongitude] = lon_dms | |
if altitude is not None: | |
gps_ifd[piexif.GPSIFD.GPSAltitudeRef] = 0 # 0 for sea level reference (above sea level) | |
gps_ifd[piexif.GPSIFD.GPSAltitude] = (int(abs(altitude * 1000)), 1000) # Altitude as rational (numerator, denominator) | |
print(f"DEBUG: Converted Altitude: {altitude} -> {gps_ifd[piexif.GPSIFD.GPSAltitude]}") | |
else: | |
print("DEBUG: Altitude is None.") | |
else: | |
print("DEBUG: Final GPS values were missing or 0.0. Skipping GPS IFD creation.") | |
if gps_ifd: | |
exif_dict["GPS"] = gps_ifd | |
print("DEBUG: GPS IFD (Image File Directory) prepared for embedding.") | |
else: | |
print("DEBUG: GPS IFD is empty. No GPS data to embed.") | |
try: | |
exif_bytes = piexif.dump(exif_dict) | |
piexif.insert(exif_bytes, image_path) | |
print(f"SUCCESS: Successfully embedded data into: {os.path.basename(image_path)}") | |
processed_successfully = True # Set success flag for piexif path | |
except Exception as e: | |
print(f"ERROR: Failed to embed data into {os.path.basename(image_path)}: {e}") | |
processed_successfully = False # Set failure flag | |
print(f"--- DEBUG: Finished processing {os.path.basename(image_path)} ---") | |
return (processed_successfully, file_was_renamed) | |
def process_directory(directory_path): | |
""" | |
Processes all JPG/JPEG/HEIC images in the specified directory (and subdirectories) | |
and embeds GPS data from corresponding JSON files, handling various naming patterns. | |
""" | |
print(f"Starting to process images in: {os.path.abspath(directory_path)}") | |
if not os.path.isdir(directory_path): | |
print(f"ERROR: The specified path '{directory_path}' is not a valid directory.") | |
return | |
processed_count = 0 | |
skipped_count = 0 | |
images_without_json = [] | |
renamed_files_count = 0 # Counter for renamed files | |
# Using os.walk to recursively process subdirectories | |
for root, _, files in os.walk(directory_path): | |
for filename in files: | |
# Check for image file extensions (expanded to include .heic) | |
if filename.lower().endswith(('.jpg', '.jpeg', '.heic')): | |
image_path = os.path.join(root, filename) | |
found_json = False | |
json_path = None # Initialize json_path to None | |
print(f"DEBUG_PDIR: Processing image: '{filename}'") | |
# Strategy 1: Check standard suffixes (using the full image filename) | |
for suffix in STANDARD_JSON_SUFFIXES: | |
candidate_json_filename = filename + suffix | |
current_json_path = os.path.join(root, candidate_json_filename) | |
print(f"DEBUG_PDIR: Trying standard JSON: '{os.path.basename(current_json_path)}'") | |
if os.path.exists(current_json_path): | |
json_path = current_json_path | |
found_json = True | |
print(f"DEBUG: Found JSON with standard suffix: '{os.path.basename(json_path)}'") | |
break # Found a JSON, no need to check other suffixes | |
# Strategy 2: Check for the special (N) pattern if a standard one wasn't found | |
if not found_json: | |
base_name, ext = os.path.splitext(filename) | |
print(f"DEBUG_PDIR: Standard JSON not found. Checking (N) pattern for base_name='{base_name}', ext='{ext}'") | |
match = IMAGE_DUPLICATE_PATTERN.match(base_name) | |
if match: | |
original_base_name = match.group(1) | |
duplicate_number = match.group(2) | |
print(f"DEBUG_PDIR: Image name matched (N) pattern: original_base_name='{original_base_name}', duplicate_number='{duplicate_number}'") | |
# Construct the JSON filename for this specific pattern | |
# Example: IMG_1234(1).JPG.supplemental-metadata.json | |
# The original image filename is embedded here | |
candidate_json_filename = f"{original_base_name}{ext}.supplemental-metadata({duplicate_number}).json" | |
current_json_path = os.path.join(root, candidate_json_filename) | |
print(f"DEBUG_PDIR: Trying (N) pattern JSON: '{os.path.basename(current_json_path)}'") | |
if os.path.exists(current_json_path): | |
json_path = current_json_path | |
found_json = True | |
print(f"DEBUG: Found JSON with (N) duplicate pattern: '{os.path.basename(json_path)}'") | |
else: | |
print(f"DEBUG_PDIR: (N) pattern JSON does NOT exist: '{os.path.basename(current_json_path)}'") | |
else: | |
print(f"DEBUG_PDIR: Image name '{base_name}' does NOT match (N) pattern.") | |
# Proceed if a JSON file was found | |
if found_json and json_path: | |
processed_status, renamed_status = embed_gps_data(image_path, json_path) | |
if processed_status: | |
processed_count += 1 | |
if renamed_status: | |
renamed_files_count += 1 # Increment renamed counter | |
else: | |
print(f"\nINFO: No matching supplemental-metadata JSON found for: {os.path.basename(filename)} with any known suffix. Skipping.") | |
images_without_json.append(filename) | |
skipped_count += 1 | |
print("\n--- Processing Summary ---") | |
print(f"Total images processed (with JSONs found): {processed_count}") | |
print(f"Total images skipped (no JSON found): {skipped_count}") | |
print(f"Total image files renamed (HEIC to JPG): {renamed_files_count}") | |
if images_without_json: | |
print("\nImages for which no JSON metadata was found:") | |
for img_name in images_without_json: | |
print(f" - {img_name}") | |
print("Processing complete.") | |
if __name__ == "__main__": | |
# --- IMPORTANT: Ensure 'piexif' is installed (pip install piexif) --- | |
# --- For HEIC support, you ALSO need ExifTool installed and in your system's PATH --- | |
# On macOS: brew install exiftool | |
# On Debian/Ubuntu: sudo apt-get install libimage-exiftool-perl | |
# For Windows/other: Download from exiftool.org and add to PATH | |
# Check for command-line arguments | |
if len(sys.argv) > 1: | |
target_folder = sys.argv[1] # Get the first argument as the folder path | |
# It's good practice to normalize the path | |
target_folder = os.path.abspath(target_folder) | |
else: | |
# If no argument is provided, default to the current directory | |
target_folder = os.getcwd() | |
print("No directory path provided as an argument. Processing the current directory.") | |
process_directory(target_folder) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment