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
77Usage:
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"""
1017import argparse
1118import random
4653# Poster extends to middle of border lines (half the line thickness)
4754BORDER_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
50172def 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+
231381def 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"\n Processing { 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"\n Processing { 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"\n Done! Processed { len (png_files )} images." )
454+ print (f"\n Done! Processed { len (image_files )} images." )
285455
286456
287457def 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
325509if __name__ == '__main__' :
0 commit comments