Skip to content

Instantly share code, notes, and snippets.

@debuti
Last active June 13, 2025 22:27
Show Gist options
  • Save debuti/d773fdf7cf9f6940f60cfb082abf59ee to your computer and use it in GitHub Desktop.
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.
#!/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