import sys import argparse from pathlib import Path from PIL import Image import piexif # ------------------------------------------------------------------- # --- How to Use This Script --- # # 1. Install Dependencies (if you haven't already): # python3 -m pip install pillow piexif # # 2. Run from your terminal. # # # Example 1: Set ONLY 35mm equivalent # python3 focal2exif.py "C:\Path\To\Images" --f_pixels 3200.0 # # # Example 2: Set BOTH focal lengths # python3 focal2exif.py "C:\Path\To\Images" --f_pixels 3200.0 --sensor_width 23.5 # # # Example 3: Set all information # python3 focal2exif.py "C:\Path\To\Images" --f_pixels 3200.0 --sensor_width 23.5 --make "Sony" --model "ILCE-7M4" # # # Example 4: Set 35mm equivalent and camera model # python3 focal2exif.py "C:\Path\To\Images" --f_pixels 3200.0 --model "MyCustomCam" # # ------------------------------------------------------------------- # --- Helper Function --- def float_to_rational(f, precision=10000): """ Converts a float to a rational (numerator, denominator) for EXIF representation. """ numerator = int(f * precision) denominator = precision return (numerator, denominator) # --- Main Processing Function --- def process_image(image_path_str, f_px, sensor_w_mm=None, camera_make=None, camera_model=None): """ Calculates focal lengths and writes them (and optionally make/model) to the image's EXIF data. """ try: image_path = Path(image_path_str) # 1. Get image width in pixels using Pillow with Image.open(image_path) as img: image_width_px = img.width if image_width_px == 0: print(f"SKIPPING: {image_path.name} (Image width is 0)") return # 2. Perform the calculations # Formula for 35mm equivalent focal length f_35mm_equiv = f_px * (36.0 / image_width_px) f_mm = None # Formula for focal length (mm) if sensor_w_mm: f_mm = f_px * (sensor_w_mm / image_width_px) # 3. Load existing EXIF data or create a new dict try: exif_dict = piexif.load(str(image_path)) except piexif.InvalidExif: print(f"INFO: No valid EXIF data in {image_path.name}. Creating new.") exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} except Exception: print(f"INFO: No EXIF data in {image_path.name}. Creating new.") exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} # 4. Set the new EXIF tags # --- 0th IFD (ImageIFD) Tags --- # Make and Model go here if "0th" not in exif_dict: exif_dict["0th"] = {} if camera_make: # piexif.ImageIFD.Make (Tag 271) exif_dict["0th"][piexif.ImageIFD.Make] = camera_make if camera_model: # piexif.ImageIFD.Model (Tag 272) exif_dict["0th"][piexif.ImageIFD.Model] = camera_model # --- Exif IFD Tags --- # Focal lengths go here if "Exif" not in exif_dict: exif_dict["Exif"] = {} # piexif.ExifIFD.FocalLengthIn35mmFilm (Tag 41989) exif_dict["Exif"][piexif.ExifIFD.FocalLengthIn35mmFilm] = int(round(f_35mm_equiv)) if f_mm is not None: # piexif.ExifIFD.FocalLength (Tag 37386) exif_dict["Exif"][piexif.ExifIFD.FocalLength] = float_to_rational(f_mm) # 5. Dump the EXIF data to bytes exif_bytes = piexif.dump(exif_dict) # 6. Insert the new EXIF bytes into the image file piexif.insert(exif_bytes, str(image_path)) # 7. Print summary print(f"PROCESSED: {image_path.name}") if camera_make: print(f" > Set Make: {camera_make}") if camera_model: print(f" > Set Model: {camera_model}") if f_mm is not None: print(f" > Set FocalLength: {f_mm:.2f}mm") print(f" > Set FocalLengthIn35mmFilm: {int(round(f_35mm_equiv))}mm") except Exception as e: print(f"ERROR processing {image_path_str}: {e}") # --- Main execution --- def main(): parser = argparse.ArgumentParser( description="Batch add focal length (mm and 35mm-equivalent) and camera " "make/model to EXIF data from a given focal length in pixels.", epilog="Usage Examples:\n" " # Set only 35mm equivalent\n" " python3 %(prog)s \"./my_photos\" --f_pixels 3200.0\n\n" " # Set both focal lengths\n" " python3 %(prog)s \"./my_photos\" --f_pixels 3200.0 --sensor_width 23.5\n\n" " # Set all available info\n" " python3 %(prog)s \"./my_photos\" --f_pixels 3200.0 --sensor_width 23.5 --make \"MyMake\" --model \"MyModel\"\n", formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument("folder", help="Path to the folder containing images.") parser.add_argument( "--f_pixels", type=float, required=True, help="The focal length in pixels (e.g., 'fx' from a camera matrix)." ) parser.add_argument( "--sensor_width", type=float, required=False, default=None, help="[Optional] The physical width of the camera sensor in mm " "(e.g., 36.0 for full-frame). If provided, the standard " "FocalLength (mm) will also be set." ) parser.add_argument( "--make", type=str, required=False, default=None, help="[Optional] The camera manufacturer (e.g., 'Sony', 'Canon', 'Apple')." ) parser.add_argument( "--model", type=str, required=False, default=None, help="[Optional] The camera model name (e.g., 'ILCE-7M4', 'iPhone 15 Pro')." ) args = parser.parse_args() # --- Validate folder --- folder_path = Path(args.folder) if not folder_path.is_dir(): print(f"Error: '{args.folder}' is not a valid directory.") sys.exit(1) IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.tif', '.tiff'} print(f"Scanning '{folder_path}'...") print(f"Using: Focal Length (pixels) = {args.f_pixels}") if args.sensor_width: print(f"Using: Sensor Width = {args.sensor_width}mm (for standard FocalLength)") else: print("INFO: No sensor width provided. Only setting 35mm equivalent.") if args.make: print(f"Using: Make = {args.make}") if args.model: print(f"Using: Model = {args.model}") print("-" * 30) # Use rglob to recursively find all files for file_path in folder_path.rglob('*'): if file_path.suffix.lower() in IMAGE_EXTENSIONS: process_image( str(file_path), args.f_pixels, args.sensor_width, args.make, args.model ) print("-" * 30) print("Batch processing complete.") if __name__ == "__main__": main()