Automated Photo Categorization

Having a lot of photos not categorized could require some (a lot) of effort to get to a point where you end up with a situation fully based on a standard like <year>/<month>/<location>/<file>.

I decided to realize a very small, easy and maybe not so "clean" bash program that takes in input:

  • the action to perform (copy files or create symlinks)
  • the folder in which look for photos
  • the folder in which create the organized structure 
  • how many levels in the source folder traverse

With all the information collected the program will go through each file found, analyze it and use an external reverse geocoding platform (thank you a lot OpenStreetMap) to fill the gap between raw gps coordinates and address.

Before even trying to use the program by yourself on any Linux platform, make sure you have installed:

  • basename
  • bash
  • cp
  • curl
  • cut
  • date
  • exiftool
  • find
  • iconv
  • jq
  • ln
  • mkdir
  • numfmt
  • readlink
  • sed
  • tr
  • xargs

And now ... below the code 😉

#!/bin/bash

VERSION="0.1"

LANG=""
export LANG

oIFS=$IFS
IFS=$'\n'
export IFS

INPUTDIR=""
LEVELS=""
OUTPUTDIR=""
MODE=""

MODE="$1"
INPUTDIR="$2"
OUTPUTDIR="$3"
LEVELS="$4"

if [[ "$MODE" != "c" && "$MODE" != "s" ]]; then
    echo "Mode is not set to a valid value (c|s)"
    exit 1
fi
if [[ "$INPUTDIR" != "" && ! -d "$INPUTDIR" ]]; then
    echo "Input dir '${INPUTDIR}' is not a valid directory"
    exit 2
fi
if [[ "$OUTPUTDIR" != "" && ! -d "$OUTPUTDIR" ]]; then
    echo "Output dir '${OUTPUTDIR}' is not a valid directory"
    exit 3
fi
if [[ "$LEVELS" != "" && ! "$LEVELS" =~ ^[0-9]+$ ]]; then
    echo "Nesting level is not a number"
    exit 4
fi

[ "$LEVELS" = "" ] && LEVELS=9999

if [[ "$INPUTDIR" = "" && "$OUTPUTDIR" = "" ]]; then

    echo ""
    echo "Media - Image - Geo Organizer"
    echo "ver: ${VERSION} - Davide Airaghi"
    echo "Usage: "
    echo "- command:  media-img-time_geo.sh <MODE> <INDIR> <OUTDIR> <LEVELS>"
    echo "- param <MODE>  : c: copy file, s: create symlink"
    echo "- param <INDIR> : directory containing images to classify"
    echo "- param <OUTDIR>: into which directory copy & organize files"
    echo "- param <LEVELS>: how many nesting levels consider (min: 1) while searching"
    echo "Notes: "
    echo "- file will be organized as  <YEAR> / <MONTH> / <LOCALITY> / <FILE>"
    echo "- <YEAR> and <MONTH> will be extracted by metadata, file name, file modifcation date"
    echo "- <LOCALITY> will be extrated by GPS coordinates found in file metadata, whenever possible"
    echo "- if no locality can be found, <FILE> will be put in <YEAR> / <MONTH>"
    echo ""
    exit 0

fi

if [[ "$INPUTDIR" = "" || "$OUTPUTDIR" = "" ]]; then
    echo "Input directory and/or Output directory not provided"
    exit 5
fi

declare -A fCOORDS
fLAT=""
fLNG=""
fLOC=""
fJSON=""
fDATE=""
fDEST=""

for FILE in $(find . -maxdepth 1 -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.webp" -o -iname "*.heic" \) 2>/dev/null) ;
#for FILE in 20201020-WA0000.jpg ;
do

    FILE="$( echo $FILE | sed -e 's/^\.\///' )"

    echo "Analyzing ${FILE} ..."

    fLAT="$(  exiftool -n         -GPSLatitude  -c '%.4f' $FILE 2>/dev/null | cut -d: -f2 | xargs | LANG=en numfmt --field=1- --format=%.4f --invalid=ignore )"
    fLNG="$(  exiftool -n         -GPSLongitude -c '%.4f' $FILE 2>/dev/null | cut -d: -f2 | xargs | LANG=en numfmt --field=1- --format=%.4f --invalid=ignore )"
    fDATE="$( exiftool -d "%Y/%m" -CreateDate             $FILE 2>/dev/null | cut -d: -f2 | xargs )"
    fLOC=""

    echo -n "- Checking GPS Coords: "
    if [[ $fLAT != "" && $fLNG != "" ]]; then

        fKEY="${fLAT},${fLNG}"

        _CACHED="(not cached)"
        if [ "${fCOORDS[$fKEY]}" = "" ]; then
            sleep 1.5
            fJSON="$( curl "https://nominatim.openstreetmap.org/reverse?lat=${fLAT}&lon=${fLNG}&format=json" 2>/dev/null )"
            _COUNTRY="$( echo "$fJSON" | jq .address.country 2>/dev/null )"
            _CITY="$( echo "$fJSON" | jq .address.city 2>/dev/null)"
            _VILLAGE="$( echo "$fJSON" | jq .address.village 2>/dev/null )"
            _TOWN="$( echo "$fJSON" | jq .address.town 2>/dev/null )"
            [[ "${_CITY}" = "null" || "${_CITY}" = "NULL" ]] && _CITY=""
            [[ "${_TOWN}" = "null" || "${_TOWN}" = "NULL" ]] && _TOWN=""
            [[ "${_VILLAGE}" = "null" || "${_VILLAGE}" = "NULL" ]] && _VILLAGE=""
            [ "${_CITY}" = "" ] && _CITY="${_VILLAGE}"
            [ "${_CITY}" = "" ] && _CITY="${_TOWN}"
            fLOC="$( echo "${_COUNTRY}-${_CITY}" | tr . _ | tr -d '"' | tr ' ' '_' | tr "'" '_' | iconv -f utf-8 -t ascii//translit )"
            fLOC="${fLOC^^}"
        else
            fLOC="${fCOORDS[$fKEY]}"
            _CACHED="(cache)"
        fi

        fLOC=$( echo $fLOC | sed -e 's/^NULL//' | sed -e 's/-NULL$//' | grep -v -E '\-$' | xargs )

        [ "${fLOC}"  = "-" ] && fLOC=""
        [ "${fLOC}" != ""  ] && fCOORDS[$fKEY]="${fLOC}"

        [ "${fLOC}"  = "-" ] && echo "Not found"
        [ "${fLOC}" != ""  ] && echo "Found - ${fLAT};${fLNG};${fLOC};${_CACHED}"
    else

        echo "Not found"

    fi

    echo -n "- Checking Date & Time: "
    # if empty date, trying the filename
    if [ "${fDATE}" = "" ]; then
        fDATE="$( basename $FILE | sed -e 's/^IMG.//g' | sed -e 's/^img.//g' | sed -e 's/^PIC.//g' | sed -e 's/^pic.//g' | sed -e 's/^VIDEO.//g' | sed -e 's/^video.//g' | sed -e 's/^VID.//g' | sed -e 's/^vid.//g' )"
        fDATE="$( echo $fDATE | sed -e 's/-WA.*$//' | tr -d '-' | tr -d '_' | tr -d '.' )"
        fDATE="$(date -d ${fDATE} +%Y/%m 2>/dev/null | xargs )"
    fi
    # if still empty date, using file attribute
    if [ "${fDATE}" = "" ]; then
        fDATE="$( exiftool -d "%Y/%m" -FileModifyDate $FILE 2>/dev/null | cut -d: -f2 | xargs )"
    fi
    [ "${fDATE}"  = "" ] && echo "Not found"
    [ "${fDATE}" != "" ] && echo "Found - ${fDATE}"

    fDEST=`readlink -m ${OUTPUTDIR}/${fDATE}/${fLOC}/`
    mkdir -p $fDEST 2>/dev/null >/dev/null

    fDEST="${fDEST}/$(basename $FILE)"
    case "$MODE" in
        "c")
            echo "- Copying file to ${fDEST}"
            cp "$FILE" "${fDEST}"
        ;;
        "s")
            echo "- Absolute Symlinking file to ${fDEST}"
            ln -s `readlink -f $FILE` "${fDEST}"
        ;;
        *)
            echo "- Skipping for unknow 'mode'"
        ;;
    esac

done