Skip to main content
Bash Script:

Digitally frame photos with PhotoFramer

Summary

PhotoFramer is a Bash script that adds a custom frame with EXIF data, GPS location, and camera settings to your images. It extracts date, time, ISO, exposure, aperture, and focal length, then composites an avatar and location text into a framed image without modifying originals.

Introduction #

I recently wrote PhotoFramer, a photo framing script that adds a frame and EXIF information to images without modifying originals. The script includes an avatar, location, ISO, exposure time, aperture, and focal length in the output. It is released under the MIT license. You can find the repository at https://github.com/j15k/photoframer.

Framed image examples #

You can find many examples of images framed with PhotoFramer here:

How the script works #

PhotoFramer processes each image file in a specified input directory. For each image, the script extracts metadata using exiftool and ImageMagick identify. It then creates a canvas with a white frame, composites the original image onto the canvas, adds the avatar image, and finally annotates the metadata text. The output is saved to a separate directory without modifying the original files. It adds a top frame of 50 pixels and a bottom frame of 200 pixels, with a 50 pixel padding on the left and right sides.

Avatar placement #

The script places a user-specified avatar image in the bottom left corner of the frame. The avatar is resized to 100x100 pixels while preserving its original dots per inch (DPI) for print quality.

The script retrieves the avatar path from the AVATAR variable and checks the DPI using ImageMagick:

AVATAR_DPI=$(identify -format "%x" "$AVATAR" 2>/dev/null | head -1 | cut -d' ' -f1 | sed 's/[^0-9.]//g')
if [ -z "$AVATAR_DPI" ] || [ "$AVATAR_DPI" = "0" ] || [ "$AVATAR_DPI" = "." ]; then
  AVATAR_DPI=300
fi

The composite command places the avatar with an offset from the bottom:

\( "$AVATAR" -resize ${AVATAR_SIZE}x${AVATAR_SIZE} -density "${AVATAR_DPI}" -units PixelsPerInch -background none \) \
-gravity southwest \
-geometry +${FRAME_PADDING}+${AVATAR_OFFSET_FROM_BOTTOM} \
-composite

Date and time extraction #

The script extracts the original capture date and time from the EXIF tag DateTimeOriginal. If this tag does not exist, it falls back to the file modification date.

The script converts the 24-hour format to a 12-hour format with AM or PM:

hour=$(echo "$time_part" | cut -d':' -f1 | sed 's/^0*//')
minute=$(echo "$time_part" | cut -d':' -f2 | sed 's/^0*//')

if [ "$hour" -ge 12 ]; then
  if [ "$hour" -eq 12 ]; then
    display_hour=12
  else
    display_hour=$((hour - 12))
  fi
  ampm="PM"
else
  if [ "$hour" -eq 0 ]; then
    display_hour=12
  else
    display_hour=$hour
  fi
  ampm="AM"
fi

time_formatted=$(printf "%d:%02d %s" "$display_hour" "$minute" "$ampm")
datetime_combined="${date_part}  ·  ${time_formatted}"

Location from GPS coordinates #

If the image contains Global Positioning System (GPS) coordinates in its EXIF data, the script uses the Nominatim reverse geocoding Application Programming Interface (API) to convert the coordinates into a human-readable location string.

The script first checks a local cache file to avoid repeated API calls:

if [ -f "$CACHE_FILE" ]; then
  location=$(grep "^${cache_key}|" "$CACHE_FILE" | cut -d'|' -f2)
  if [ -n "$location" ] && [ "$location" != "null" ] && [ "$location" != "__NOLOCATION__" ]; then
    echo "$location"
    return 0
  fi
fi

For coordinates stored in degrees, minutes, seconds (DMS) format, the script converts them to decimal degrees using the dms_to_decimal function:

dms_to_decimal() {
  local dms="$1"
  local direction="$2"
  local decimal=0
  
  if [[ "$dms" =~ ([0-9.]+)[^0-9]*([0-9.]+)[^0-9]*([0-9.]+)? ]]; then
    local degrees="${BASH_REMATCH[1]}"
    local minutes="${BASH_REMATCH[2]}"
    local seconds="${BASH_REMATCH[3]:-0}"
    
    decimal=$(echo "scale=8; $degrees + $minutes/60 + $seconds/3600" | bc)
    
    if [[ "$direction" == "S" ]] || [[ "$direction" == "W" ]]; then
      decimal=$(echo "scale=8; -$decimal" | bc)
    fi
  fi
  
  echo "$decimal"
}

The script then calls the Nominatim API with a User-Agent header and email for fair usage compliance:

response=$(curl -s "https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=18&addressdetails=1&accept-language=${NOMINATIM_ACCEPT_LANGUAGE}" \
  -H "User-Agent: ${NOMINATIM_USER_AGENT}" \
  -H "From: ${NOMINATIM_EMAIL}" 2>/dev/null)

ISO extraction #

The script attempts four different methods to extract the International Organization for Standardization (ISO) sensitivity value from the image metadata:

# Method 1: Direct EXIF tag with identify
iso_raw=$(identify -format "%[EXIF:ISOSpeedRatings]" "$img" 2>/dev/null | xargs)

# Method 2: Try ISO tag with identify
iso_raw=$(identify -format "%[EXIF:ISO]" "$img" 2>/dev/null | xargs)

# Method 3: Try exiftool with multiple tag names
for tag in "ISO" "ISOSpeed" "ISOSpeedRatings" "BaseISO" "RecommendedExposureIndex"; do
  iso_raw=$(exiftool -$tag -s3 "$img" 2>/dev/null | head -1 | xargs)

# Method 4: Try exiftool with EXIF:ISO
iso_raw=$(exiftool -EXIF:ISO -s3 "$img" 2>/dev/null | head -1 | xargs)

Each method cleans the output to retain only numeric characters and formats the result as “ISO ".

Exposure time extraction #

The script uses exiftool to read the exposure time, preserving the fraction format when available:

exposure=$(exiftool -ExposureTime -s3 "$img" 2>/dev/null | head -1)
if [ -n "$exposure" ] && [ "$exposure" != "null" ] && [ "$exposure" != "-" ]; then
  if [[ "$exposure" == *"/"* ]]; then
    exposure="${exposure}s"
  else
    exposure_decimal="$exposure"
    if (( $(echo "$exposure_decimal < 1" | bc -l) )); then
      denominator=$(echo "scale=0; 1 / $exposure_decimal + 0.5" | bc)
      exposure="1/${denominator}s"
    else
      exposure="${exposure_decimal}s"
    fi
  fi
fi

Aperture and focal length extraction #

The aperture (f-number) and focal length are extracted using ImageMagick identify:

aperture=$(identify -format "%[EXIF:FNumber]" "$img" 2>/dev/null)
focal=$(identify -format "%[EXIF:FocalLength]" "$img" 2>/dev/null | sed 's/[^0-9\/]//g')

Both values are converted from fractional representation to decimal numbers using bc when necessary.

Final image composition #

After extracting all metadata, the script constructs the left text (location + date and time) and right text (ISO, exposure, aperture, focal length). The final ImageMagick convert command composites everything:

convert -size "${new_width}x${new_height}" xc:"$FRAME_COLOR" \
  "$img" -geometry +${FRAME_PADDING}+${TOP_FRAME_HEIGHT} -composite \
  -font "$FONT" \
  -pointsize $FONT_SIZE \
  \( "$AVATAR" -resize ${AVATAR_SIZE}x${AVATAR_SIZE} -density "${AVATAR_DPI}" -units PixelsPerInch -background none \) \
  -gravity southwest \
  -geometry +${FRAME_PADDING}+${AVATAR_OFFSET_FROM_BOTTOM} \
  -composite \
  -gravity southwest \
  -fill "$TEXT_COLOR" \
  -annotate +$((FRAME_PADDING + AVATAR_SIZE + FRAME_PADDING - 4))+${LEFT_TEXT_FROM_BOTTOM} "$left_text" \
  -gravity southeast \
  -fill "$TEXT_COLOR" \
  -annotate +${FRAME_PADDING}+${RIGHT_TEXT_FROM_BOTTOM} "$right_text" \
  "$OUTPUT_DIR/$filename"

Download and run the PhotoFrame script #

  1. Download the script from the GitHub repository or copy it from this article.
  2. Save the script as photoframer.sh.
  3. Make the script executable:
    chmod +x photoframer.sh
    
  4. Edit the configuration variables at the top of the script:
    • Set INPUT_DIR to your source image directory
    • Set OUTPUT_DIR to your destination directory
    • Replace NOMINATIM_EMAIL with your own email address
    • Set AVATAR to the path of your avatar image
    • Set FONT to the path of your preferred TrueType font file
  5. Run the script:
    ./photoframer.sh
    

The script processes all supported images in the input directory and writes the framed versions to the output directory. A log file named log.txt and a geocode cache file named geocode_cache.txt are created in the output directory.

PhotoFramer dependencies #

PhotoFramer requires the following command-line tools. Install them using your distribution package manager:

sudo apt install coreutils imagemagick exiftool bc curl jq
  • realpath (from coreutils): Resolves absolute paths for directory safety checks
  • identify and convert (from ImageMagick): Image processing and metadata extraction
  • exiftool: Advanced EXIF data reading
  • bc: Command-line calculator for decimal and fractional conversions
  • curl: HTTP client for Nominatim API requests
  • jq: JSON parser for Nominatim API responses

The script also requires a TrueType font file and an avatar image in Portable Network Graphics (PNG) or Joint Photographic Experts Group (JPEG) format.

Contribute #

This is the first version of the script. Although many features have been tested extensively, the script might contain bugs. You can contribute by reporting issues, suggesting features, or submitting pull requests.

Visit the GitHub repository issue tracker.

FAQ's #

Most common questions and brief, easy-to-understand answers on the topic:

Does PhotoFramer modify the original image files?

No, PhotoFramer writes output to a separate directory (framed by default). The script includes a directory safety check and will exit with an error if input and output directories are the same.

What happens if my image has no GPS coordinates?

The script skips the location lookup. You can enable SHOW_COORDINATES_AS_FALLBACK="true" in the script to display raw coordinates instead, or leave the location field empty.

Can I use my own font and avatar image?

Yes. Set the FONT variable to your font file path and AVATAR to your avatar image path. The script falls back to a system sans font if your specified font is not found.

Does the script work with videos?

No. PhotoFramer processes only image files with the supported extensions: jpg, jpeg, JPG, JPEG, png, PNG.

Why do I need to provide an email address for the Nominatim API?

Nominatim requires a contact email address to respect fair usage policies. The script sends this email in the HTTP request header. Replace the placeholder with your own email address before using the script.

How does the script handle time zones in EXIF data?

The script reads DateTimeOriginal as stored in the EXIF data. It does not convert time zones. Times are displayed as recorded by the camera.

Further readings #

Sources and recommended, further resources on the topic:

Author

Jonas Jared Jacek • J15k

Jonas Jared Jacek (J15k)

Jonas works as project manager, web designer, and web developer since 2001. On top of that, he is a Linux system administrator with a broad interest in things related to programming, architecture, and design. See: https://www.j15k.com/

License

Digitally frame photos with PhotoFramer by Jonas Jared Jacek is licensed under CC BY-SA 4.0.

This license requires that reusers give credit to the creator. It allows reusers to distribute, remix, adapt, and build upon the material in any medium or format, for noncommercial purposes only. To give credit, provide a link back to the original source, the author, and the license e.g. like this:

<p xmlns:cc="http://creativecommons.org/ns#" xmlns:dct="http://purl.org/dc/terms/"><a property="dct:title" rel="cc:attributionURL" href="https://www.ditig.com/digitally-frame-photos-with-photoframer">Digitally frame photos with PhotoFramer</a> by <a rel="cc:attributionURL dct:creator" property="cc:attributionName" href="https://www.j15k.com/">Jonas Jared Jacek</a> is licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="license noopener noreferrer">CC BY-SA 4.0</a>.</p>

For more information see the Ditig legal page.

All Topics

Random Quote

“PowerPoint corrupts absolutely.”

Vinton Gray Cerf American Internet pioneer, one of "the fathers of the Internet"geoff-hart.com, - IT quotes