Skip to content

Commit f69fc2e

Browse files
jeremymanningclaude
andcommitted
Enhance add_borders.py with smart cropping and resizing
- Add --face flag for face-detection centered cropping (uses mediapipe) - Always crop non-square images to square (center crop by default) - Resize images if max dimension exceeds 1000px - Accept individual files in addition to directories - Support PNG, JPG, JPEG input formats - Cache face detection model in scripts/ directory 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent b8133e8 commit f69fc2e

File tree

3 files changed

+223
-30
lines changed

3 files changed

+223
-30
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,8 @@ __pycache__/
2424
venv/
2525
.venv/
2626

27+
# ML models (downloaded on demand)
28+
*.tflite
29+
2730
# Claude skills (local only)
2831
.claude/

requirements-build.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,11 @@ requests>=2.31.0
2121
# Screenshot comparison for visual regression tests
2222
Pillow>=10.0.0
2323

24+
# Image processing for border script
25+
numpy>=1.24.0
26+
27+
# Face detection for smart cropping (optional)
28+
mediapipe>=0.10.0
29+
2430
# Browser automation for visual tests
2531
selenium>=4.15.0

scripts/add_borders.py

Lines changed: 214 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
#!/usr/bin/env python3
2-
"""Add hand-drawn borders to poster thumbnail images.
2+
"""Add hand-drawn borders to images (posters, profile photos, etc.).
33
4-
Takes PNG images and adds randomly selected borders from an SVG template,
5-
producing output images suitable for the publications page.
4+
Takes image files and adds randomly selected borders from an SVG template.
5+
Images are automatically cropped to square and resized if needed.
66
77
Usage:
8-
python add_poster_borders.py <input_dir> <output_dir> [--border-svg <path>]
8+
# Process individual files
9+
python add_borders.py image1.png image2.jpg output_dir/
10+
11+
# Process directory of images
12+
python add_borders.py input_dir/ output_dir/
13+
14+
# With face detection for smart cropping
15+
python add_borders.py photo.png output_dir/ --face
916
"""
1017
import argparse
1118
import random
@@ -46,6 +53,121 @@
4653
# Poster extends to middle of border lines (half the line thickness)
4754
BORDER_INSET = 6
4855

56+
# Maximum dimension for input images (larger images are resized)
57+
MAX_INPUT_DIMENSION = 1000
58+
59+
60+
def resize_to_max_dimension(img: Image.Image, max_size: int = MAX_INPUT_DIMENSION) -> Image.Image:
61+
"""Resize image so max dimension is max_size, preserving aspect ratio.
62+
63+
If the image is already smaller than max_size in both dimensions,
64+
it is returned unchanged.
65+
66+
Args:
67+
img: PIL Image to resize
68+
max_size: Maximum allowed dimension (default 1000px)
69+
70+
Returns:
71+
Resized image (or original if already small enough)
72+
"""
73+
width, height = img.size
74+
if max(width, height) <= max_size:
75+
return img
76+
77+
if width > height:
78+
new_width = max_size
79+
new_height = int(height * (max_size / width))
80+
else:
81+
new_height = max_size
82+
new_width = int(width * (max_size / height))
83+
84+
return img.resize((new_width, new_height), Image.Resampling.LANCZOS)
85+
86+
87+
def get_face_detector():
88+
"""Get or create a mediapipe face detector (cached)."""
89+
if not hasattr(get_face_detector, '_detector'):
90+
import mediapipe as mp
91+
from mediapipe.tasks import python as mp_python
92+
from mediapipe.tasks.python import vision
93+
import urllib.request
94+
import os
95+
96+
# Download the face detection model if not present
97+
model_path = Path(__file__).parent / 'blaze_face_short_range.tflite'
98+
if not model_path.exists():
99+
url = "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite"
100+
print(f" Downloading face detection model...")
101+
urllib.request.urlretrieve(url, str(model_path))
102+
103+
# Create face detector
104+
base_options = mp_python.BaseOptions(model_asset_path=str(model_path))
105+
options = vision.FaceDetectorOptions(base_options=base_options)
106+
get_face_detector._detector = vision.FaceDetector.create_from_options(options)
107+
108+
return get_face_detector._detector
109+
110+
111+
def crop_to_square(img: Image.Image, use_face_detection: bool = False) -> Image.Image:
112+
"""Crop image to square, centered on face (if requested) or image center.
113+
114+
The crop size is the minimum of width and height (no upscaling).
115+
116+
Args:
117+
img: PIL Image to crop
118+
use_face_detection: If True, use mediapipe to detect face and center
119+
crop on it. Falls back to center if no face found.
120+
121+
Returns:
122+
Square-cropped image
123+
"""
124+
width, height = img.size
125+
if width == height:
126+
return img
127+
128+
crop_size = min(width, height)
129+
130+
# Default to center crop
131+
center_x, center_y = width // 2, height // 2
132+
133+
if use_face_detection:
134+
try:
135+
import mediapipe as mp
136+
137+
# Convert to RGB for mediapipe
138+
img_rgb = img.convert('RGB')
139+
140+
# Save to temp file for mediapipe (it needs a file path or specific format)
141+
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
142+
img_rgb.save(tmp.name)
143+
mp_image = mp.Image.create_from_file(tmp.name)
144+
145+
try:
146+
detector = get_face_detector()
147+
result = detector.detect(mp_image)
148+
149+
if result.detections:
150+
# Get first face bounding box (in pixels)
151+
bbox = result.detections[0].bounding_box
152+
center_x = bbox.origin_x + bbox.width // 2
153+
center_y = bbox.origin_y + bbox.height // 2
154+
print(f" Face detected at ({center_x}, {center_y})")
155+
else:
156+
print(" No face detected, using center crop")
157+
finally:
158+
Path(tmp.name).unlink(missing_ok=True)
159+
160+
except ImportError:
161+
print(" Warning: mediapipe not installed, using center crop")
162+
except Exception as e:
163+
print(f" Warning: Face detection failed ({e}), using center crop")
164+
165+
# Calculate crop bounds, ensuring we stay within image boundaries
166+
left = max(0, min(center_x - crop_size // 2, width - crop_size))
167+
top = max(0, min(center_y - crop_size // 2, height - crop_size))
168+
169+
return img.crop((left, top, left + crop_size, top + crop_size))
170+
49171

50172
def extract_border_from_svg(svg_path: Path, region_idx: int, output_size: int) -> Image.Image:
51173
"""Extract a single border region from the SVG and render it as PNG.
@@ -228,19 +350,49 @@ def add_border_to_image(
228350
return output
229351

230352

353+
def collect_image_files(inputs: list) -> list:
354+
"""Collect all image files from a list of files and directories.
355+
356+
Args:
357+
inputs: List of Path objects (files or directories)
358+
359+
Returns:
360+
List of image file paths (PNG, JPG, JPEG)
361+
"""
362+
image_extensions = {'.png', '.jpg', '.jpeg'}
363+
image_files = []
364+
365+
for input_path in inputs:
366+
if input_path.is_file():
367+
if input_path.suffix.lower() in image_extensions:
368+
image_files.append(input_path)
369+
else:
370+
print(f" Skipping non-image file: {input_path}")
371+
elif input_path.is_dir():
372+
for ext in image_extensions:
373+
image_files.extend(input_path.glob(f'*{ext}'))
374+
image_files.extend(input_path.glob(f'*{ext.upper()}'))
375+
376+
# Remove duplicates and sort
377+
image_files = sorted(set(image_files))
378+
return image_files
379+
380+
231381
def process_images(
232-
input_dir: Path,
382+
inputs: list,
233383
output_dir: Path,
234384
svg_path: Path,
235-
output_size: int = OUTPUT_SIZE
385+
output_size: int = OUTPUT_SIZE,
386+
use_face_detection: bool = False
236387
) -> None:
237-
"""Process all PNG images in input directory, adding borders.
388+
"""Process images, adding borders after optional crop and resize.
238389
239390
Args:
240-
input_dir: Directory containing input PNG files
391+
inputs: List of input files or directories
241392
output_dir: Directory to save output files
242393
svg_path: Path to SVG file with border designs
243394
output_size: Size of output images (square)
395+
use_face_detection: Use face detection for centering crops
244396
"""
245397
output_dir.mkdir(parents=True, exist_ok=True)
246398

@@ -258,46 +410,70 @@ def process_images(
258410
if not borders:
259411
raise RuntimeError("No borders could be loaded!")
260412

261-
# Process each PNG in input directory
262-
png_files = list(input_dir.glob('*.png'))
263-
print(f"\nProcessing {len(png_files)} images...")
413+
# Collect all image files from inputs
414+
image_files = collect_image_files(inputs)
415+
if not image_files:
416+
print("No image files found to process.")
417+
return
264418

265-
for png_path in png_files:
266-
print(f" Processing {png_path.name}...")
419+
print(f"\nProcessing {len(image_files)} images...")
420+
if use_face_detection:
421+
print(" (Face detection enabled)")
267422

268-
# Load poster image
269-
poster = Image.open(png_path)
270-
if poster.mode != 'RGBA':
271-
poster = poster.convert('RGBA')
423+
for img_path in image_files:
424+
print(f" Processing {img_path.name}...")
272425

273-
# Select random border
274-
border = random.choice(borders)
426+
# Load image
427+
img = Image.open(img_path)
428+
if img.mode != 'RGBA':
429+
img = img.convert('RGBA')
430+
431+
original_size = img.size
275432

276-
# Composite
277-
result = add_border_to_image(poster, border)
433+
# Step 1: Crop to square (with optional face detection)
434+
img = crop_to_square(img, use_face_detection=use_face_detection)
435+
if original_size[0] != original_size[1]: # Was not square before
436+
print(f" Cropped to {img.size[0]}x{img.size[1]}")
278437

279-
# Save as RGBA to preserve transparent margins
280-
output_path = output_dir / png_path.name
438+
# Step 2: Resize if larger than max dimension
439+
pre_resize = img.size
440+
img = resize_to_max_dimension(img)
441+
if img.size != pre_resize:
442+
print(f" Resized to {img.size[0]}x{img.size[1]}")
443+
444+
# Step 3: Select random border and composite
445+
border = random.choice(borders)
446+
result = add_border_to_image(img, border)
447+
448+
# Save as PNG (output name based on input, always .png)
449+
output_name = img_path.stem + '.png'
450+
output_path = output_dir / output_name
281451
result.save(output_path, 'PNG', optimize=True)
282452
print(f" Saved to {output_path}")
283453

284-
print(f"\nDone! Processed {len(png_files)} images.")
454+
print(f"\nDone! Processed {len(image_files)} images.")
285455

286456

287457
def main():
288458
parser = argparse.ArgumentParser(
289-
description='Add hand-drawn borders to poster thumbnail images'
459+
description='Add hand-drawn borders to images (posters, profile photos, etc.)'
290460
)
291461
parser.add_argument(
292-
'input_dir',
462+
'inputs',
293463
type=Path,
294-
help='Directory containing input PNG files'
464+
nargs='+',
465+
help='Input PNG/JPG files or directories containing images'
295466
)
296467
parser.add_argument(
297468
'output_dir',
298469
type=Path,
299470
help='Directory to save output files'
300471
)
472+
parser.add_argument(
473+
'--face',
474+
action='store_true',
475+
help='Use face detection to center crop on detected face'
476+
)
301477
parser.add_argument(
302478
'--border-svg',
303479
type=Path,
@@ -313,13 +489,21 @@ def main():
313489

314490
args = parser.parse_args()
315491

316-
if not args.input_dir.exists():
317-
raise FileNotFoundError(f"Input directory not found: {args.input_dir}")
492+
# Validate inputs exist
493+
for input_path in args.inputs:
494+
if not input_path.exists():
495+
raise FileNotFoundError(f"Input not found: {input_path}")
318496

319497
if not args.border_svg.exists():
320498
raise FileNotFoundError(f"Border SVG not found: {args.border_svg}")
321499

322-
process_images(args.input_dir, args.output_dir, args.border_svg, args.output_size)
500+
process_images(
501+
args.inputs,
502+
args.output_dir,
503+
args.border_svg,
504+
args.output_size,
505+
use_face_detection=args.face
506+
)
323507

324508

325509
if __name__ == '__main__':

0 commit comments

Comments
 (0)