diff --git a/.gitignore b/.gitignore index 13d1490..c475c9e 100644 --- a/.gitignore +++ b/.gitignore @@ -118,6 +118,9 @@ venv.bak/ # Rope project settings .ropeproject +# VSCode project settings +.vscode + # mkdocs documentation /site @@ -129,3 +132,5 @@ dmypy.json # Pyre type checker .pyre/ +# Project specific +test-folder \ No newline at end of file diff --git a/README.md b/README.md index 76dd33e..addc810 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ -# photo-datestamper +## ๐Ÿ“ท๐Ÿ“† - photo-datestamper -Little script to add date caption to photos from their EXIF data. \ No newline at end of file +Little script to add date caption to photos from their EXIF data. + +## Notes on Exif data + +There is [ExifRead](https://github.com/ianare/exif-py) or [Exif](https://exif.readthedocs.io/en/latest/index.html) which can also modify exif data (and is tested and documented). + +Both seem to be alive but `ExifRead` should suffice. + +```python +with open(file, 'rb') as f: + # Donโ€™t process makernote tags, donโ€™t extract the thumbnail image (if any) + tags = exif.process_file(f, details=False) +for tag in tags.keys(): + if tag not in ('JPEGThumbnail', 'TIFFThumbnail', 'Filename', 'EXIF MakerNote'): +``` + +Interesting keys (to be completed by further observations): +``` +Image Orientation +Image DateTime +EXIF DateTimeOriginal +EXIF DateTimeDigitized +EXIF ExifImageWidth +EXIF ExifImageLength +``` \ No newline at end of file diff --git a/coverage.txt b/coverage.txt new file mode 100644 index 0000000..a8dc3ea --- /dev/null +++ b/coverage.txt @@ -0,0 +1,10 @@ +1920x1080_1.jpg test-folder\source\1920x1080_0.JPG +1920x1080_3.jpg test-folder\source\1920x1080_180.JPG +1920x1080_6.jpg test-folder\source\1920x1080_270.JPG +1920x1080_8.jpg test-folder\source\1920x1080_90.JPG +2048x1536_1.jpg test-folder\source\2048x1536_0.jpg +2048x1536_6.jpg test-folder\source\2048x1536_270.jpg +3840x2160_1.jpg test-folder\source\Rรฉfรฉrence.JPG +2160x3840_1.jpg test-folder\source\3840x2160_0.JPG +3840x2160_3.jpg test-folder\source\3840x2160_180.JPG +3840x2160_6.jpg test-folder\source\3840x2160_270.JPG diff --git a/datestamper.py b/datestamper.py new file mode 100644 index 0000000..0a45e41 --- /dev/null +++ b/datestamper.py @@ -0,0 +1,131 @@ +""" +Given a source folder and a destination folder, this script will copy all images +and add to them a white date stamp in the correct orientation at the correct size. + +It uses ImageMagick, so make sure you've it installed already! +http://www.imagemagick.org +""" +import logging +from pathlib import Path +import subprocess + +import exifread as exif + +import logger + +# log is preset by the logger module +log = logging.getLogger(logger.LOG_NAME) + + +SRC_PATH = Path("test-folder/source") +DST_PATH = Path("test-folder/destination") +USED_KEYS = { + "Image Orientation", + "EXIF DateTimeOriginal", + "EXIF ExifImageWidth", + "EXIF ExifImageLength", +} +DATE_SEP = "." +SIZE_REF = 2160 + +COVERAGE_FILE = Path("coverage.txt") + + +def get_metadata(path, keys): + """Helper function to retrieve specific tags.""" + with open(path, "rb") as file: + tags = exif.process_file(file, details=False) + + shorten = lambda item: item[0] if len(item) == 1 else item + + return {k: shorten(tags[k].values) for k in keys} + + +def stamp(file): + """Datestamps the image pointed to by file.""" + meta = get_metadata(file, USED_KEYS) + + # EXIF Date format: "YYYY:MM:DD HH:MM:SS" with time shown in 24-hour format + date = meta["EXIF DateTimeOriginal"].split()[0].split(":")[::-1] + date_stamp = DATE_SEP.join(date) + + min_dimension = min(meta["EXIF ExifImageWidth"], meta["EXIF ExifImageLength"]) + pointsize = (min_dimension * 100) // SIZE_REF + offset_ratio = (min_dimension * 15) // SIZE_REF + + # โš  Warning: magic numbers ahead + orientation = meta["Image Orientation"] + if orientation == 6: + angle = "270x270" + gravity = "NorthEast" + y_offset = 2 * offset_ratio + x_offset = 6 * offset_ratio + elif orientation == 8: + angle = "90x90" + gravity = "SouthWest" + y_offset = 37 * offset_ratio + x_offset = 2 * offset_ratio + elif orientation == 3: + angle = "180x180" + gravity = "NorthWest" + y_offset = 8 * offset_ratio + x_offset = 37 * offset_ratio + elif orientation == 1: + angle = "" + gravity = "SouthEast" + y_offset = 0 + x_offset = offset_ratio + else: + log.error(f"๐Ÿ”ด Unkown orientation '{orientation}' for file {file}") + return None + + log.info(f"Processing {file}...") + log.debug( + f"Metadata: {orientation=}, " + f"{meta['EXIF ExifImageWidth']}x{meta['EXIF ExifImageLength']}, " + f"{meta['EXIF DateTimeOriginal']}" + ) + # Gestion de l'argument de commande sur Windows : https://stackoverflow.com/a/35900070 + # ร€ explorer, notamment pour l'utilisation de l'argument "shell" + command = ( + f'convert "{file}" ' + f"-gravity {gravity} " + f"-fill white " + f"-pointsize {pointsize} " + f"-annotate {angle}+{x_offset}+{y_offset} {date_stamp} " + f'"{DST_PATH}\{file.name}"' + ) + log.debug(f"Executing command: {command}") + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + log.error(f"๐Ÿ”ด Command failed for {file}: {result.stderr}") + return None + else: + return f"{meta['EXIF ExifImageWidth']}x{meta['EXIF ExifImageLength']}_{orientation}{file.suffix.lower()}" + + +if __name__ == "__main__": + log.info("๐Ÿ“ท๐Ÿ“† - Welcome to Photo DateStamper!") + log.info(f"๐Ÿ“ Log file: {logger.LOG_FILE}") + DST_PATH.mkdir(parents=True, exist_ok=True) + COVERAGE_FILE.touch(exist_ok=True) + with open(COVERAGE_FILE, "r") as file: + lines = file.readlines() + coverage = {line.split()[0]: line.split()[1] for line in lines} + new_coverage = dict() + try: + for file in SRC_PATH.iterdir(): + fingerprint = stamp(file) + if fingerprint is not None and fingerprint not in coverage: + new_coverage[fingerprint] = str(file) + finally: + if new_coverage: + log.info( + f"๐ŸŸก Execution encountered {len(new_coverage)} new combinations, please report them." + ) + log.info(f"๐Ÿ“ Updating coverage file: {COVERAGE_FILE}") + with open(COVERAGE_FILE, "a", encoding="UTF-8") as file: + for k, v in new_coverage.items(): + file.write(f"{k} {v}\n") + + log.info("๐ŸŸข Processing done.") \ No newline at end of file diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..4cd506e --- /dev/null +++ b/logger.py @@ -0,0 +1,50 @@ +""" +Logging facility for the application. +This module is to be imported from userland. +After import, the logger object should be retrieved like that: +log = logging.getLogger("datestemper.") +""" + +import logging +import logging.handlers as handlers +import os +from pathlib import Path + + +# Relative to working directory +LOG_NAME = "datestamper" +LOG_DIR = "log" +LOG_FILE = Path(Path.cwd(), LOG_DIR, LOG_NAME + ".log") + + +# Create log dir & file if does not exists +LOG_FILE.parent.mkdir(parents=True, exist_ok=True) +LOG_FILE.touch(exist_ok=True) + + +root_logger = logging.getLogger(LOG_NAME) +root_logger.setLevel(logging.DEBUG) + +logger_fmt = logging.Formatter( + fmt="{asctime}.{msecs:03.0f} [{levelname:.1s}] [{name}] {message}", + datefmt="%Y-%m-%d %H:%M:%S", + style="{", +) + +logger_fmt_lite = logging.Formatter( + fmt="[{levelname:.1s}] {message}", + style="{", +) + +root_logger_sh = logging.StreamHandler() +root_logger_sh.setLevel(logging.INFO) +root_logger_sh.setFormatter(logger_fmt_lite) + +root_logger_fh = logging.handlers.RotatingFileHandler( + filename=LOG_FILE, encoding="UTF-8", maxBytes=1024 +) +root_logger_fh.setLevel(logging.DEBUG) +root_logger_fh.setFormatter(logger_fmt) + +root_logger.addHandler(root_logger_sh) +root_logger.addHandler(root_logger_fh) \ No newline at end of file