Created
July 11, 2025 09:59
-
-
Save nilsreichardt/c5e56898a24eff8ef1ab1b9a2ff258ce to your computer and use it in GitHub Desktop.
US Passport Image to German Passport Image
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 | |
""" | |
make_passport_sheet.py | |
Create a 10 × 15 cm print sheet containing six German-passport-size photos | |
(35 mm × 45 mm) from a square 2 × 2-inch source image. | |
Usage: | |
python make_passport_sheet.py input.jpg output.jpg --dpi 300 | |
""" | |
import argparse | |
from pathlib import Path | |
from math import floor | |
from PIL import Image | |
# ----------------------------- constants ----------------------------- | |
MM_PER_IN = 25.4 | |
PASS_MM_W = 35 # passport width mm | |
PASS_MM_H = 45 # passport height mm | |
SHEET_MM_W = 100 # sheet width mm (10 cm) | |
SHEET_MM_H = 150 # sheet height mm (15 cm) | |
COLS, ROWS = 2, 3 # we can fit 2 × 3 = 6 photos | |
# --------------------------------------------------------------------- | |
def mm_to_px(mm: float, dpi: int) -> int: | |
"""Convert millimetres to integer pixels at the given dpi.""" | |
return round(mm * dpi / MM_PER_IN) | |
def crop_to_passport_aspect(img: Image.Image) -> Image.Image: | |
"""Center-crop the image to the 7:9 (35:45) aspect ratio.""" | |
w, h = img.size | |
target_ar = PASS_MM_W / PASS_MM_H # 0.777… | |
current_ar= w / h | |
if current_ar > target_ar: # too wide → crop sides | |
new_w = round(h * target_ar) | |
left = (w - new_w) // 2 | |
box = (left, 0, left + new_w, h) | |
else: # too tall → crop top/bottom | |
new_h = round(w / target_ar) | |
top = (h - new_h) // 2 | |
box = (0, top, w, top + new_h) | |
return img.crop(box) | |
def build_sheet(cropped: Image.Image, dpi: int) -> Image.Image: | |
"""Place 6 passport-size copies on a 10 × 15 cm sheet.""" | |
# 1) exact pixel sizes | |
pass_px_w = mm_to_px(PASS_MM_W, dpi) | |
pass_px_h = mm_to_px(PASS_MM_H, dpi) | |
sheet_w = mm_to_px(SHEET_MM_W, dpi) | |
sheet_h = mm_to_px(SHEET_MM_H, dpi) | |
# 2) resize the cropped headshot | |
passport = cropped.resize((pass_px_w, pass_px_h), Image.LANCZOS) | |
# 3) compute equal gutter margins | |
# let Gx = horizontal gutter between columns plus 2×outer margins | |
# Gy = vertical gutter between rows plus 2×outer margins | |
total_free_x = sheet_w - COLS * pass_px_w | |
total_free_y = sheet_h - ROWS * pass_px_h | |
gutter_x = total_free_x // (COLS + 1) | |
gutter_y = total_free_y // (ROWS + 1) | |
sheet = Image.new("RGB", (sheet_w, sheet_h), "white") | |
for r in range(ROWS): | |
for c in range(COLS): | |
x = gutter_x + c * (pass_px_w + gutter_x) | |
y = gutter_y + r * (pass_px_h + gutter_y) | |
sheet.paste(passport, (x, y)) | |
return sheet | |
def main(): | |
ap = argparse.ArgumentParser() | |
ap.add_argument("input", type=Path, help="source 2×2-inch image file") | |
ap.add_argument("output", type=Path, help="output 10×15 cm sheet file") | |
ap.add_argument("--dpi", type=int, default=300, | |
help="print resolution (default: 300 dpi)") | |
args = ap.parse_args() | |
# 1) read & crop | |
img = Image.open(args.input).convert("RGB") | |
cropped = crop_to_passport_aspect(img) | |
# 2) build sheet | |
sheet = build_sheet(cropped, args.dpi) | |
# 3) embed correct DPI in the saved file | |
sheet.save(args.output, dpi=(args.dpi, args.dpi), quality=95) | |
print(f"✅ Saved {args.output} ({COLS*ROWS} photos) at {args.dpi} dpi") | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment