Initial commit: CRT photobooth
This commit is contained in:
248
contact_sheet.py
Normal file
248
contact_sheet.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""Contact sheet composition and printing.
|
||||
|
||||
Source photos are always 4:3 landscape (e.g. 800x600). A "strip" is one
|
||||
shoot's `photos_per_shoot` photos arranged as a VERTICAL COLUMN with a
|
||||
footer block beneath the column (footer is half a photo tall). The sheet
|
||||
holds `shoots_per_print` strips side by side.
|
||||
|
||||
`compute_layout` picks, per (photos_per_shoot, shoots_per_print), the
|
||||
combination of *rotation mode*, *page orientation*, and *strip columns*
|
||||
that maximizes each printed photo's area on a fixed 8.5x11 letter sheet
|
||||
(2550x3300 at 300 DPI). The two rotation modes are:
|
||||
|
||||
- ``portrait_ccw`` : each photo rotated 90 deg counter-clockwise so the
|
||||
cell is portrait 3:4. Best when strips are tall
|
||||
and narrow (e.g. 3 photos per shoot, 3 shoots).
|
||||
- ``landscape_none``: photos kept landscape 4:3 (no rotation). Best when
|
||||
a strip has few photos and a wide page fills better
|
||||
(e.g. 1 photo per shoot, or one big photo).
|
||||
|
||||
The date is printed only inside the footer.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# US letter at 300 DPI, both orientations.
|
||||
PAGE_W_PORTRAIT, PAGE_H_PORTRAIT = 2550, 3300
|
||||
PAGE_W_LANDSCAPE, PAGE_H_LANDSCAPE = 3300, 2550
|
||||
|
||||
FOOTER_H_RATIO = 0.5 # footer height = half one photo's height
|
||||
|
||||
# Rotation modes: (name, cell aspect W:H, PIL transpose used in build_sheet).
|
||||
# ROTATE_90 = 90 deg CCW ; ROTATE_270 = 90 deg CW ; None = no rotation.
|
||||
ROTATION_MODES = [
|
||||
("portrait_ccw", 3, 4, Image.Transpose.ROTATE_90),
|
||||
("landscape_none", 4, 3, None),
|
||||
]
|
||||
|
||||
|
||||
def _divisors(n):
|
||||
out = []
|
||||
for d in range(1, n + 1):
|
||||
if n % d == 0:
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _hex_color(s, default=(0, 0, 0)):
|
||||
s = (s or "").strip().lstrip("#")
|
||||
if len(s) == 6:
|
||||
try:
|
||||
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
|
||||
except ValueError:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _load_font(size):
|
||||
try:
|
||||
return ImageFont.truetype("DejaVuSans-Bold.ttf", size)
|
||||
except OSError:
|
||||
try:
|
||||
return ImageFont.truetype("DejaVuSans.ttf", size)
|
||||
except OSError:
|
||||
return ImageFont.load_default(size)
|
||||
|
||||
|
||||
def compute_layout(photos_per_shoot, shoots_per_print, margin,
|
||||
photo_gutter, strip_gutter, auto_orient=True):
|
||||
"""Return the optimal sheet layout as a dict.
|
||||
|
||||
A strip is always a vertical column of `photos_per_shoot` photos with
|
||||
a footer beneath it. We try every rotation mode, every column count
|
||||
that divides `shoots_per_print` (so the grid of strips has no empty
|
||||
cells), and (when auto_orient) both page orientations, then choose the
|
||||
layout with the largest printed photo cell area.
|
||||
"""
|
||||
pps = max(1, int(photos_per_shoot))
|
||||
spp = max(1, int(shoots_per_print))
|
||||
|
||||
orientations = [(PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, "portrait"),
|
||||
(PAGE_W_LANDSCAPE, PAGE_H_LANDSCAPE, "landscape")]
|
||||
if not auto_orient:
|
||||
orientations = orientations[:1]
|
||||
|
||||
best = None # (photo_area, layout_dict)
|
||||
|
||||
for mode_name, aw, ah, transpose in ROTATION_MODES:
|
||||
for cols in _divisors(spp):
|
||||
rows = spp // cols
|
||||
for page_w, page_h, orient_name in orientations:
|
||||
avail_w = page_w - 2 * margin
|
||||
avail_h = page_h - 2 * margin
|
||||
if avail_w <= 0 or avail_h <= 0:
|
||||
continue
|
||||
|
||||
# Width budget: cols*cell_w + (cols-1)*strip_gutter <= avail_w
|
||||
cell_w_from_w = (avail_w - (cols - 1) * strip_gutter) // cols
|
||||
|
||||
# Height budget. One strip of `pps` stacked photos + a
|
||||
# footer half a photo tall:
|
||||
# strip_h = pps*cell_h + (pps-1)*photo_gutter + photo_gutter + footer_h
|
||||
# = cell_h*(pps + FOOTER_H_RATIO) + pps*photo_gutter
|
||||
# Grid height: rows*strip_h + (rows-1)*strip_gutter <= avail_h
|
||||
# => cell_h <= (avail_h - (rows-1)*strip_gutter - pps*photo_gutter)
|
||||
# / (rows*(pps + FOOTER_H_RATIO))
|
||||
strip_gap_total_h = (rows - 1) * strip_gutter
|
||||
photo_gaps_total_h = pps * photo_gutter
|
||||
avail_for_cells_h = avail_h - strip_gap_total_h - photo_gaps_total_h
|
||||
denom_h = rows * (pps + FOOTER_H_RATIO)
|
||||
cell_h_from_h = int(avail_for_cells_h / denom_h) if denom_h > 0 else 0
|
||||
|
||||
# Convert between cell_w and cell_h using this mode's aspect.
|
||||
cell_w_from_h = cell_h_from_h * aw // ah
|
||||
cell_h_from_w = cell_w_from_w * ah // aw
|
||||
|
||||
cell_h = min(cell_h_from_h, cell_h_from_w)
|
||||
if cell_h <= 0:
|
||||
continue
|
||||
cell_w = cell_h * aw // ah
|
||||
if cell_w <= 0:
|
||||
continue
|
||||
cell_w = min(cell_w, cell_w_from_w)
|
||||
cell_h = cell_w * ah // aw # keep aspect exact after clamp
|
||||
if cell_h <= 0:
|
||||
continue
|
||||
|
||||
footer_h = int(cell_h * FOOTER_H_RATIO)
|
||||
strip_h = (pps * cell_h + (pps - 1) * photo_gutter
|
||||
+ photo_gutter + footer_h)
|
||||
grid_w = cols * cell_w + (cols - 1) * strip_gutter
|
||||
grid_h = rows * strip_h + (rows - 1) * strip_gutter
|
||||
if grid_w > avail_w or grid_h > avail_h:
|
||||
continue
|
||||
|
||||
x0 = margin + (avail_w - grid_w) // 2
|
||||
y0 = margin + (avail_h - grid_h) // 2
|
||||
|
||||
photo_rects = []
|
||||
footer_rects = []
|
||||
for strip_i in range(spp):
|
||||
scol = strip_i // rows
|
||||
srow = strip_i % rows
|
||||
sx = x0 + scol * (cell_w + strip_gutter)
|
||||
sy = y0 + srow * (strip_h + strip_gutter)
|
||||
for pi in range(pps):
|
||||
py = sy + pi * (cell_h + photo_gutter)
|
||||
photo_rects.append((sx, py, cell_w, cell_h))
|
||||
fy = sy + pps * cell_h + (pps - 1) * photo_gutter + photo_gutter
|
||||
footer_rects.append((sx, fy, cell_w, footer_h))
|
||||
|
||||
photo_area = cell_w * cell_h
|
||||
cand = {
|
||||
"page_w": page_w, "page_h": page_h,
|
||||
"orientation": orient_name,
|
||||
"mode": mode_name,
|
||||
"transpose": transpose,
|
||||
"cols": cols, "rows": rows,
|
||||
"cell_w": cell_w, "cell_h": cell_h,
|
||||
"footer_h": footer_h,
|
||||
"strip_h": strip_h, "grid_w": grid_w, "grid_h": grid_h,
|
||||
"x0": x0, "y0": y0,
|
||||
"photo_rects": photo_rects,
|
||||
"footer_rects": footer_rects,
|
||||
"photo_area": photo_area,
|
||||
}
|
||||
if best is None or photo_area > best[0]:
|
||||
best = (photo_area, cand)
|
||||
|
||||
if best is None:
|
||||
raise ValueError(
|
||||
f"No contact sheet layout fits photos_per_shoot={pps}, "
|
||||
f"shoots_per_print={spp} on a letter page.")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _render_footer(width, height, footer_cfg):
|
||||
"""Build the footer image: bg fill, line1, line2, then date at bottom."""
|
||||
line1 = (footer_cfg or {}).get("line1", "")
|
||||
line2 = (footer_cfg or {}).get("line2", "")
|
||||
bg = _hex_color((footer_cfg or {}).get("background_color"), (0, 0, 0))
|
||||
fg = _hex_color((footer_cfg or {}).get("text_color"), (255, 255, 255))
|
||||
|
||||
img = Image.new("RGB", (width, height), bg)
|
||||
draw = ImageDraw.Draw(img)
|
||||
pad = max(6, height // 12)
|
||||
title_font = _load_font(max(24, height // 5))
|
||||
date_font = _load_font(max(18, height // 7))
|
||||
|
||||
y = pad
|
||||
for line in (line1, line2):
|
||||
if not line:
|
||||
continue
|
||||
tw = draw.textlength(line, font=title_font)
|
||||
draw.text(((width - tw) // 2, y), line, fill=fg, font=title_font)
|
||||
y += title_font.getbbox(line)[3] + pad // 2
|
||||
|
||||
stamp = datetime.now().strftime("%B %d, %Y")
|
||||
dw = draw.textlength(stamp, font=date_font)
|
||||
db = date_font.getbbox(stamp)
|
||||
draw.text(((width - dw) // 2, height - db[3] - pad // 2),
|
||||
stamp, fill=fg, font=date_font)
|
||||
return img
|
||||
|
||||
|
||||
def build_sheet(photo_paths, out_path, footer_cfg=None,
|
||||
photos_per_shoot=3, shoots_per_print=3,
|
||||
margin=120, photo_gutter=30, strip_gutter=30,
|
||||
auto_orient=True):
|
||||
"""Compose photos onto a letter page and save it to out_path."""
|
||||
layout = compute_layout(photos_per_shoot, shoots_per_print,
|
||||
margin, photo_gutter, strip_gutter, auto_orient)
|
||||
sheet = Image.new("RGB", (layout["page_w"], layout["page_h"]), "white")
|
||||
|
||||
photos = list(photo_paths)[:len(layout["photo_rects"])]
|
||||
footer_img = _render_footer(layout["cell_w"], layout["footer_h"], footer_cfg)
|
||||
|
||||
transpose = layout["transpose"]
|
||||
for i, path in enumerate(photos):
|
||||
x, y, w, h = layout["photo_rects"][i]
|
||||
photo = Image.open(path)
|
||||
if transpose is not None:
|
||||
photo = photo.transpose(transpose)
|
||||
photo = photo.resize((w, h), Image.Resampling.LANCZOS)
|
||||
sheet.paste(photo, (x, y))
|
||||
|
||||
for (x, y, w, h) in layout["footer_rects"]:
|
||||
sheet.paste(footer_img, (x, y))
|
||||
|
||||
sheet.save(out_path, quality=95)
|
||||
return out_path
|
||||
|
||||
|
||||
def print_sheet(path, printer=""):
|
||||
"""Send the sheet to the printer via lp. Returns (ok, message)."""
|
||||
cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page"]
|
||||
if printer:
|
||||
cmd += ["-d", printer]
|
||||
cmd.append(str(path))
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return False, f"lp failed to run: {e}"
|
||||
if result.returncode != 0:
|
||||
return False, result.stderr.strip() or "lp returned an error"
|
||||
return True, result.stdout.strip()
|
||||
Reference in New Issue
Block a user