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 #
- Download the script from the GitHub repository or copy it from this article.
- Save the script as
photoframer.sh. - Make the script executable:
chmod +x photoframer.sh - Edit the configuration variables at the top of the script:
- Set
INPUT_DIRto your source image directory - Set
OUTPUT_DIRto your destination directory - Replace
NOMINATIM_EMAILwith your own email address - Set
AVATARto the path of your avatar image - Set
FONTto the path of your preferred TrueType font file
- Set
- 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 checksidentifyandconvert(from ImageMagick): Image processing and metadata extractionexiftool: Advanced EXIF data readingbc: Command-line calculator for decimal and fractional conversionscurl: HTTP client for Nominatim API requestsjq: 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:
- GitHub: PhotoFramer repository
- ImageMagick: Command-line Tools
- ExifTool: Read and write meta information
- Nominatim: Reverse Geocoding API Documentation
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.