First commit: "works on my computer!"
This commit is contained in:
parent
b53508c346
commit
3cbfe18fbe
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
26
README.md
26
README.md
@ -1,3 +1,27 @@
|
||||
# photo-datestamper
|
||||
## 📷📆 - photo-datestamper
|
||||
|
||||
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
|
||||
```
|
||||
10
coverage.txt
Normal file
10
coverage.txt
Normal file
@ -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
|
||||
131
datestamper.py
Normal file
131
datestamper.py
Normal file
@ -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.")
|
||||
50
logger.py
Normal file
50
logger.py
Normal file
@ -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.<whatever>")
|
||||
"""
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user