Last active
June 13, 2025 22:27
-
-
Save debuti/d773fdf7cf9f6940f60cfb082abf59ee to your computer and use it in GitHub Desktop.
A script to create tiled ID photos ready for printing with specified dimensions and DPI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
__author__ = "Borja García <[email protected]>" | |
__version__ = "1.1.0" | |
__license__ = "CC BY-SA License" | |
__description__ = "A script to create tiled ID photos ready for printing with specified dimensions and DPI." | |
from dataclasses import dataclass | |
import shutil | |
import sys | |
import argparse | |
from pathlib import Path | |
from PIL import Image | |
class ImageError(Exception): | |
"""Custom exception for image processing errors.""" | |
@dataclass | |
class ImageInfo: | |
input_path: Path | |
tmp_path: Path | |
width_px: int | |
height_px: int | |
dpi: int | |
def __init__(self, input_path): | |
with Image.open(input_path) as img: | |
width_px, height_px = img.size | |
dpi = img.info.get("dpi", (None, None)) | |
# Check if DPI is set and both directions are the same | |
if dpi[0] is None or dpi[1] is None: | |
print("❌ Warning: DPI information is missing.") | |
raise ImageError() | |
elif dpi[0] != dpi[1]: | |
print( | |
f"❌ Warning: DPI values are not the same in both directions: {dpi}" | |
) | |
raise ImageError() | |
else: | |
print(f"✅ DPI is set and the same in both directions: {int(dpi[0])}") | |
self.input_path = input_path | |
ext = ".tmp" + "".join(input_path.suffixes) | |
self.tmp_path = input_path | |
while self.tmp_path.suffix != "": | |
self.tmp_path = self.tmp_path.with_suffix("") | |
self.tmp_path = self.tmp_path.with_suffix(ext) | |
self.width_px = width_px | |
self.height_px = height_px | |
self.dpi = int(dpi[0]) | |
shutil.copy(self.input_path, self.tmp_path) | |
def resize(self, new_size_mm): | |
width_mm, height_mm = new_size_mm | |
width_px = int(mm_to_inch(width_mm) * self.dpi) | |
height_px = int(mm_to_inch(height_mm) * self.dpi) | |
# Only resize if the original image is larger than the target size | |
if self.width_px < width_px or self.height_px < height_px: | |
print(f"❌ Warning: Input image is smaller than the target size.") | |
raise ImageError() | |
with Image.open(self.tmp_path) as img: | |
resized_img = img.resize((width_px, height_px), Image.LANCZOS) | |
resized_img.save(self.tmp_path, dpi=(self.dpi, self.dpi)) | |
self.width_px = width_px | |
self.height_px = height_px | |
print( | |
f"✅ Resized input image to {width_mm}x{height_mm} mm ({width_px}x{height_px} px at {self.dpi} DPI) and saved to {self.tmp_path}" | |
) | |
def add_border(self, border_size, color=(0, 0, 0)): | |
border_width_px, border_height_px = border_size | |
with Image.open(self.tmp_path) as img: | |
new_width_px = self.width_px + (2 * border_width_px) | |
new_height_px = self.height_px + (2 * border_height_px) | |
bordered_img = Image.new("RGB", (new_width_px, new_height_px), color) | |
bordered_img.paste(img, (border_width_px, border_height_px)) | |
bordered_img.save(self.tmp_path, dpi=(self.dpi, self.dpi)) | |
print(f"✅ Created image with {border_size} border") | |
def drop(self): | |
if self.tmp_path.exists(): | |
self.tmp_path.unlink() | |
@dataclass | |
class ImagesInfo: | |
images: list[ImageInfo] | |
input_size_mm: tuple[int, int] | |
output_size_mm: tuple[int, int] | |
output_path: Path | |
pattern: tuple[int, int] | |
min_width_px: int | None | |
min_height_px: int | None | |
dpi: int | None | |
def __init__(self, input_size, output_size, pattern, output_path): | |
self.images = [] | |
self.input_size_mm = input_size | |
self.output_size_mm = output_size | |
self.pattern = pattern | |
self.output_path = output_path | |
self.min_width_px = None | |
self.min_height_px = None | |
self.dpi = None | |
def add(self, image_info): | |
if self.dpi is None: | |
self.dpi = image_info.dpi | |
elif self.dpi != image_info.dpi: | |
raise ImageError("All images must have the same DPI.") | |
image_info.resize(self.input_size_mm) | |
self.images.append(image_info) | |
def add_borders(self, color=(0, 0, 0)): | |
rows, cols = self.pattern | |
border_width_px = int( | |
( | |
(mm_to_inch(self.output_size_mm[0]) * self.dpi / cols) | |
- self.images[0].width_px | |
) | |
/ 2 | |
) | |
border_height_px = int( | |
( | |
(mm_to_inch(self.output_size_mm[1]) * self.dpi / rows) | |
- self.images[0].height_px | |
) | |
/ 2 | |
) | |
for image_info in self.images: | |
image_info.add_border((border_width_px, border_height_px)) | |
def create_tile(self, color=(255, 255, 255)): | |
rows, cols = self.pattern | |
images = [Image.open(image_info.tmp_path) for image_info in self.images] | |
width = images[0].width | |
height = images[0].height | |
dpi = self.dpi | |
tile_width = width * cols | |
tile_height = height * rows | |
tiled_img = Image.new("RGB", (tile_width, tile_height), color) | |
idx = 0 | |
for row in range(rows): | |
for col in range(cols): | |
x = col * width | |
y = row * height | |
tiled_img.paste(images[idx % len(images)], (x, y)) | |
idx += 1 | |
tiled_img.save(self.output_path, dpi=(dpi, dpi)) | |
print(f"✅ Saved tiled image to {self.output_path}") | |
def drop(self): | |
for image_info in self.images: | |
image_info.drop() | |
def mm_to_inch(mm): | |
return mm / 25.4 | |
def check_image_ratio(image_path, check_ratio): | |
_check_ratio = check_ratio[0] / check_ratio[1] | |
tolerance = 0.01 # Allow small floating point error | |
with Image.open(image_path) as img: | |
width, height = img.size | |
ratio = width / height | |
if abs(ratio - _check_ratio) > tolerance: | |
print( | |
f"❌ Warning: {image_path} image ratio is {ratio:.4f} ({width}x{height}), not similar to {_check_ratio:.4f} ({check_ratio[0]}x{check_ratio[1]})" | |
) | |
raise ImageError() | |
print( | |
f"✅ {image_path} image ratio is {ratio:.4f} ({width}x{height}), similar to {_check_ratio:.4f} ({check_ratio[0]}x{check_ratio[1]})" | |
) | |
def main(args): | |
max_cols = args.output_size[0] // args.input_size[0] | |
max_rows = args.output_size[1] // args.input_size[1] | |
print(f"✅ Output tile: columns: {max_cols}, rows: {max_rows}") | |
try: | |
images_info = ImagesInfo(args.input_size, args.output_size, (max_rows, max_cols), args.output) | |
# Check input image information | |
for input_file in args.input: | |
check_image_ratio(input_file, args.input_size) | |
images_info.add(ImageInfo(input_file)) | |
images_info.add_borders() | |
images_info.create_tile() | |
check_image_ratio(args.output, args.output_size) | |
return 0 | |
except ImageError as e: | |
print(f"❌ Error: {e}") | |
return -1 | |
finally: | |
images_info.drop() | |
if __name__ == "__main__": | |
def is_file(parser, arg): | |
arg = Path(arg) | |
if not arg.exists() or not arg.is_file(): | |
parser.error(f"{arg} is not a valid file.") | |
return arg | |
def is_ratio(parser, arg): | |
try: | |
ratio = tuple(map(int, arg.split(":"))) | |
if len(ratio) == 2 and ratio[0] > 0 and ratio[1] > 0: | |
return ratio | |
except ValueError: | |
pass | |
parser.error(f"Invalid aspect ratio: {arg}. Use format 'width:height'.") | |
parser = argparse.ArgumentParser(description="ID Photo Montage Script") | |
parser.add_argument( | |
"input", | |
nargs="+", | |
type=lambda x: is_file(parser, x), | |
help="Path to the input image file/s", | |
) | |
parser.add_argument( | |
"--output", type=Path, required=True, help="Path to the output image file" | |
) | |
parser.add_argument( | |
"--input-size", | |
type=lambda x: is_ratio(parser, x), | |
default=(26, 32), | |
help="Imput size in mm (width:height)", | |
) | |
parser.add_argument( | |
"--output-size", | |
type=lambda x: is_ratio(parser, x), | |
default=(150, 100), | |
help="Output size in mm (width:height)", | |
) | |
parser.add_argument( | |
"--multiplier", | |
type=float, | |
default=5.0, | |
help="Increase the size of the output image by this factor, reaching better definition (default: 5.0)", | |
) | |
args = parser.parse_args() | |
args.input_size = tuple([int(x * args.multiplier) for x in args.input_size]) | |
args.output_size = tuple([int(x * args.multiplier) for x in args.output_size]) | |
sys.exit(main(args)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment