#!/usr/bin/env python3 """ Batch image resizing with multiple strategies. Supports aspect ratio maintenance, smart cropping, thumbnail generation, watermarks, format conversion, and parallel processing. """ import argparse import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import List, Optional, Tuple class ImageResizer: """Handle image resizing operations using ImageMagick.""" def __init__(self, verbose: bool = False, dry_run: bool = False): self.verbose = verbose self.dry_run = dry_run def check_imagemagick(self) -> bool: """Check if ImageMagick is available.""" try: subprocess.run( ['magick', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True ) return True except (subprocess.CalledProcessError, FileNotFoundError): return False def build_resize_command( self, input_path: Path, output_path: Path, width: Optional[int], height: Optional[int], strategy: str, quality: int, watermark: Optional[Path] = None ) -> List[str]: """Build ImageMagick resize command based on strategy.""" cmd = ['magick', str(input_path)] # Apply resize strategy if strategy == 'fit': # Fit within dimensions, maintain aspect ratio geometry = f"{width or ''}x{height or ''}" cmd.extend(['-resize', geometry]) elif strategy == 'fill': # Fill dimensions, crop excess if not width or not height: raise ValueError("Both width and height required for 'fill' strategy") cmd.extend([ '-resize', f'{width}x{height}^', '-gravity', 'center', '-extent', f'{width}x{height}' ]) elif strategy == 'cover': # Cover dimensions, may exceed if not width or not height: raise ValueError("Both width and height required for 'cover' strategy") cmd.extend(['-resize', f'{width}x{height}^']) elif strategy == 'exact': # Force exact dimensions, ignore aspect ratio if not width or not height: raise ValueError("Both width and height required for 'exact' strategy") cmd.extend(['-resize', f'{width}x{height}!']) elif strategy == 'thumbnail': # Create square thumbnail size = width or height or 200 cmd.extend([ '-resize', f'{size}x{size}^', '-gravity', 'center', '-extent', f'{size}x{size}' ]) # Add watermark if specified if watermark: cmd.extend([ str(watermark), '-gravity', 'southeast', '-geometry', '+10+10', '-composite' ]) # Output settings cmd.extend([ '-quality', str(quality), '-strip', str(output_path) ]) return cmd def resize_image( self, input_path: Path, output_path: Path, width: Optional[int], height: Optional[int], strategy: str = 'fit', quality: int = 85, watermark: Optional[Path] = None ) -> bool: """Resize a single image.""" try: # Ensure output directory exists output_path.parent.mkdir(parents=True, exist_ok=True) cmd = self.build_resize_command( input_path, output_path, width, height, strategy, quality, watermark ) if self.verbose or self.dry_run: print(f"Command: {' '.join(cmd)}") if self.dry_run: return True subprocess.run( cmd, stdout=subprocess.PIPE if not self.verbose else None, stderr=subprocess.PIPE if not self.verbose else None, check=True ) return True except subprocess.CalledProcessError as e: print(f"Error resizing {input_path}: {e}", file=sys.stderr) if not self.verbose and e.stderr: print(e.stderr.decode(), file=sys.stderr) return False except Exception as e: print(f"Error processing {input_path}: {e}", file=sys.stderr) return False def batch_resize( self, input_paths: List[Path], output_dir: Path, width: Optional[int], height: Optional[int], strategy: str = 'fit', quality: int = 85, format_ext: Optional[str] = None, watermark: Optional[Path] = None, parallel: int = 1 ) -> Tuple[int, int]: """Resize multiple images.""" success_count = 0 fail_count = 0 def process_image(input_path: Path) -> Tuple[Path, bool]: """Process single image for parallel execution.""" if not input_path.exists() or not input_path.is_file(): return input_path, False # Determine output path output_name = input_path.stem if format_ext: output_path = output_dir / f"{output_name}.{format_ext.lstrip('.')}" else: output_path = output_dir / input_path.name if not self.dry_run: print(f"Processing {input_path.name} -> {output_path.name}") success = self.resize_image( input_path, output_path, width, height, strategy, quality, watermark ) return input_path, success # Process images if parallel > 1: with ThreadPoolExecutor(max_workers=parallel) as executor: futures = [executor.submit(process_image, path) for path in input_paths] for future in as_completed(futures): _, success = future.result() if success: success_count += 1 else: fail_count += 1 else: for input_path in input_paths: _, success = process_image(input_path) if success: success_count += 1 else: fail_count += 1 return success_count, fail_count def collect_images(paths: List[Path], recursive: bool = False) -> List[Path]: """Collect image files from paths.""" image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'} images = [] for path in paths: if path.is_file() and path.suffix.lower() in image_exts: images.append(path) elif path.is_dir(): pattern = '**/*' if recursive else '*' for img_path in path.glob(pattern): if img_path.is_file() and img_path.suffix.lower() in image_exts: images.append(img_path) return images def main(): """Main entry point.""" parser = argparse.ArgumentParser( description='Batch image resizing with multiple strategies.' ) parser.add_argument( 'inputs', nargs='+', type=Path, help='Input image(s) or directory' ) parser.add_argument( '-o', '--output', type=Path, required=True, help='Output directory' ) parser.add_argument( '-w', '--width', type=int, help='Target width in pixels' ) parser.add_argument( '-h', '--height', type=int, dest='img_height', help='Target height in pixels' ) parser.add_argument( '-s', '--strategy', choices=['fit', 'fill', 'cover', 'exact', 'thumbnail'], default='fit', help='Resize strategy (default: fit)' ) parser.add_argument( '-q', '--quality', type=int, default=85, help='Output quality 0-100 (default: 85)' ) parser.add_argument( '-f', '--format', help='Output format (e.g., jpg, png, webp)' ) parser.add_argument( '-wm', '--watermark', type=Path, help='Watermark image to overlay' ) parser.add_argument( '-p', '--parallel', type=int, default=1, help='Number of parallel processes (default: 1)' ) parser.add_argument( '-r', '--recursive', action='store_true', help='Process directories recursively' ) parser.add_argument( '-n', '--dry-run', action='store_true', help='Show commands without executing' ) parser.add_argument( '-v', '--verbose', action='store_true', help='Verbose output' ) args = parser.parse_args() # Validate dimensions if not args.width and not args.img_height: print("Error: At least one of --width or --height required", file=sys.stderr) sys.exit(1) # Initialize resizer resizer = ImageResizer(verbose=args.verbose, dry_run=args.dry_run) # Check dependencies if not resizer.check_imagemagick(): print("Error: ImageMagick not found", file=sys.stderr) sys.exit(1) # Collect input images images = collect_images(args.inputs, args.recursive) if not images: print("Error: No images found", file=sys.stderr) sys.exit(1) print(f"Found {len(images)} image(s) to process") # Create output directory if not args.dry_run: args.output.mkdir(parents=True, exist_ok=True) # Process images success, fail = resizer.batch_resize( images, args.output, args.width, args.img_height, args.strategy, args.quality, args.format, args.watermark, args.parallel ) print(f"\nResults: {success} succeeded, {fail} failed") sys.exit(0 if fail == 0 else 1) if __name__ == '__main__': main()