343 lines
10 KiB
Python
343 lines
10 KiB
Python
#!/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()
|