mirror of
https://github.com/SoftFever/OrcaSlicer.git
synced 2025-12-24 00:28:38 -07:00
* init * update * Crop cover images * match setup wizard UI * update * tiertime * anycubic * anycubic * construct3d * update --------- Co-authored-by: SoftFever <softfeverever@gmail.com>
582 lines
20 KiB
Python
582 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Optimize cover images:
|
|
1. Scale the image to maintain proper margins around the content.
|
|
2. Reduce the image size using pngquant.
|
|
3. Resize the image to fit within the maximum allowed dimensions.
|
|
|
|
To run the script:
|
|
python3 optimize_cover_images.py --optimize
|
|
|
|
This script searches for *_cover.png images in ./resources/profiles/
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import shutil
|
|
from pathlib import Path
|
|
from PIL import Image, ImageChops
|
|
import argparse
|
|
|
|
|
|
def get_file_size(path):
|
|
"""Get file size in bytes."""
|
|
return os.path.getsize(path)
|
|
|
|
|
|
def format_size(size_bytes):
|
|
"""Format file size in human-readable format."""
|
|
for unit in ['B', 'KB', 'MB']:
|
|
if size_bytes < 1024.0:
|
|
return f"{size_bytes:.1f} {unit}"
|
|
size_bytes /= 1024.0
|
|
return f"{size_bytes:.1f} GB"
|
|
|
|
|
|
def check_pngquant_available():
|
|
"""Check if pngquant is available in the system."""
|
|
return shutil.which('pngquant') is not None
|
|
|
|
|
|
def optimize_png_with_pngquant(img_path, quality_range="65-80"):
|
|
"""
|
|
Optimize PNG using pngquant for better compression.
|
|
|
|
Args:
|
|
img_path: Path to PNG file
|
|
quality_range: Quality range for pngquant (e.g., "65-80")
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
# pngquant --quality 65-80 --force --ext .png image.png
|
|
result = subprocess.run(
|
|
['pngquant', '--quality', quality_range,
|
|
'--force', '--ext', '.png', str(img_path)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
return result.returncode == 0
|
|
except Exception as e:
|
|
print(f" Warning: pngquant failed: {e}")
|
|
return False
|
|
|
|
|
|
def optimize_png_pillow(img, output_path, has_transparency=True):
|
|
"""
|
|
Optimize PNG using Pillow's best compression settings.
|
|
|
|
Args:
|
|
img: PIL Image object
|
|
output_path: Path to save optimized image
|
|
has_transparency: Whether image has transparency
|
|
"""
|
|
# Use maximum compression
|
|
# compress_level: 0-9, where 9 is maximum compression (slower but smaller)
|
|
save_kwargs = {
|
|
'format': 'PNG',
|
|
'optimize': True,
|
|
'compress_level': 9
|
|
}
|
|
|
|
# For images with transparency, ensure we're saving as RGBA
|
|
if has_transparency and img.mode != 'RGBA':
|
|
img = img.convert('RGBA')
|
|
|
|
img.save(output_path, **save_kwargs)
|
|
|
|
|
|
def get_image_bbox(img):
|
|
"""
|
|
Get the bounding box of non-transparent/non-white content in an image.
|
|
|
|
Args:
|
|
img: PIL Image object
|
|
|
|
Returns:
|
|
Tuple (left, top, right, bottom) or None if image is empty
|
|
"""
|
|
# Convert to RGBA if not already
|
|
if img.mode != 'RGBA':
|
|
img = img.convert('RGBA')
|
|
|
|
# Get the alpha channel
|
|
alpha = img.split()[-1]
|
|
|
|
# Find bounding box of non-transparent pixels
|
|
bbox = alpha.getbbox()
|
|
|
|
if bbox is None:
|
|
# If all transparent, try to find non-white pixels in RGB
|
|
if img.mode == 'RGBA':
|
|
rgb = Image.new('RGB', img.size, (255, 255, 255))
|
|
rgb.paste(img, mask=img.split()[-1])
|
|
bg = Image.new('RGB', img.size, (255, 255, 255))
|
|
diff = ImageChops.difference(rgb, bg)
|
|
bbox = diff.getbbox()
|
|
|
|
return bbox
|
|
|
|
|
|
def calculate_margins(bbox, img_size):
|
|
"""
|
|
Calculate the current margins as a percentage of image size.
|
|
|
|
Args:
|
|
bbox: Tuple (left, top, right, bottom)
|
|
img_size: Tuple (width, height)
|
|
|
|
Returns:
|
|
Dict with margin percentages
|
|
"""
|
|
if bbox is None:
|
|
return None
|
|
|
|
left, top, right, bottom = bbox
|
|
width, height = img_size
|
|
|
|
content_width = right - left
|
|
content_height = bottom - top
|
|
|
|
margin_left = left / width * 100
|
|
margin_top = top / height * 100
|
|
margin_right = (width - right) / width * 100
|
|
margin_bottom = (height - bottom) / height * 100
|
|
|
|
content_width_pct = content_width / width * 100
|
|
content_height_pct = content_height / height * 100
|
|
|
|
return {
|
|
'left': margin_left,
|
|
'top': margin_top,
|
|
'right': margin_right,
|
|
'bottom': margin_bottom,
|
|
'content_width': content_width_pct,
|
|
'content_height': content_height_pct
|
|
}
|
|
|
|
|
|
def adjust_image_margins(img_path, target_content_ratio=0.84, dry_run=False, use_pngquant=False, quality_range="65-80", max_size=None):
|
|
"""
|
|
Adjust image so content takes up target_content_ratio of the image size.
|
|
|
|
Args:
|
|
img_path: Path to the image file
|
|
target_content_ratio: Target ratio of content to image size (0.84 = 84%)
|
|
dry_run: If True, don't save changes, just report
|
|
use_pngquant: Use pngquant for additional compression
|
|
quality_range: Quality range for pngquant
|
|
max_size: Maximum dimension (width or height) in pixels, None to disable
|
|
|
|
Returns:
|
|
Dict with adjustment info or None if not adjusted
|
|
"""
|
|
try:
|
|
# Get original file size
|
|
original_file_size = get_file_size(img_path)
|
|
|
|
img = Image.open(img_path)
|
|
original_size = img.size
|
|
original_mode = img.mode
|
|
|
|
# Convert to RGBA if the image has transparency
|
|
has_transparency = original_mode in ('RGBA', 'LA') or (
|
|
original_mode == 'P' and 'transparency' in img.info)
|
|
if has_transparency and img.mode != 'RGBA':
|
|
img = img.convert('RGBA')
|
|
|
|
# Resize if image is too large
|
|
was_resized = False
|
|
if max_size and (img.size[0] > max_size or img.size[1] > max_size):
|
|
# Calculate new size maintaining aspect ratio
|
|
aspect_ratio = img.size[0] / img.size[1]
|
|
if img.size[0] > img.size[1]:
|
|
new_width = max_size
|
|
new_height = int(max_size / aspect_ratio)
|
|
else:
|
|
new_height = max_size
|
|
new_width = int(max_size * aspect_ratio)
|
|
|
|
# Use high-quality resampling (LANCZOS for best quality)
|
|
# Handle both old and new Pillow API
|
|
try:
|
|
resample = Image.Resampling.LANCZOS
|
|
except AttributeError:
|
|
resample = Image.LANCZOS
|
|
|
|
img = img.resize((new_width, new_height), resample)
|
|
was_resized = True
|
|
|
|
# Get bounding box of actual content
|
|
bbox = get_image_bbox(img)
|
|
|
|
if bbox is None:
|
|
print(f" ⚠️ {img_path}: Image appears to be empty, skipping")
|
|
return None
|
|
|
|
left, top, right, bottom = bbox
|
|
content_width = right - left
|
|
content_height = bottom - top
|
|
|
|
# Calculate current content ratio
|
|
current_width_ratio = content_width / img.size[0]
|
|
current_height_ratio = content_height / img.size[1]
|
|
|
|
# Calculate margins
|
|
margins = calculate_margins(bbox, img.size)
|
|
|
|
print(f"\n📄 {img_path}")
|
|
if was_resized:
|
|
print(
|
|
f" Original Size: {original_size[0]}x{original_size[1]} → Resized to {img.size[0]}x{img.size[1]}")
|
|
print(
|
|
f" Size: {img.size[0]}x{img.size[1]} (Mode: {original_mode}, Transparency: {has_transparency})")
|
|
print(f" File: {format_size(original_file_size)}")
|
|
print(f" Content: {content_width}x{content_height} " +
|
|
f"({margins['content_width']:.1f}% x {margins['content_height']:.1f}%)")
|
|
print(f" Margins: L:{margins['left']:.1f}% T:{margins['top']:.1f}% " +
|
|
f"R:{margins['right']:.1f}% B:{margins['bottom']:.1f}%")
|
|
|
|
# Check if adjustment is needed (allow 5% tolerance)
|
|
avg_ratio = (current_width_ratio + current_height_ratio) / 2
|
|
tolerance = 0.05
|
|
|
|
if abs(avg_ratio - target_content_ratio) < tolerance:
|
|
print(f" ✓ Already properly sized (avg ratio: {avg_ratio:.2f})")
|
|
|
|
# If image was resized, we still need to save it
|
|
if was_resized and not dry_run:
|
|
optimize_png_pillow(img, img_path, has_transparency)
|
|
new_file_size = get_file_size(img_path)
|
|
|
|
if use_pngquant:
|
|
print(f" 🔧 Applying pngquant optimization...")
|
|
if optimize_png_with_pngquant(img_path, quality_range):
|
|
pngquant_size = get_file_size(img_path)
|
|
print(f" pngquant: {format_size(new_file_size)} → {format_size(pngquant_size)} " +
|
|
f"({(pngquant_size/new_file_size-1)*100:+.1f}%)")
|
|
new_file_size = pngquant_size
|
|
|
|
size_change_pct = (
|
|
new_file_size / original_file_size - 1) * 100
|
|
print(f" ✓ Saved (resized): {format_size(original_file_size)} → {format_size(new_file_size)} " +
|
|
f"({size_change_pct:+.1f}%)")
|
|
|
|
return {
|
|
'adjusted': True,
|
|
'original_size': original_file_size,
|
|
'new_size': new_file_size,
|
|
'size_saved': original_file_size - new_file_size
|
|
}
|
|
|
|
return None
|
|
|
|
# Crop to content
|
|
cropped = img.crop(bbox)
|
|
|
|
# Calculate new image size to achieve target ratio while preserving aspect ratio
|
|
# We want: content_size / new_image_size = target_ratio
|
|
# So: new_image_size = content_size / target_ratio
|
|
# But we need to maintain the original aspect ratio
|
|
|
|
original_aspect_ratio = img.size[0] / img.size[1]
|
|
|
|
# Calculate required sizes for each dimension
|
|
required_width = content_width / target_content_ratio
|
|
required_height = content_height / target_content_ratio
|
|
|
|
# Choose the larger requirement to ensure content fits within target ratio
|
|
# Then adjust the other dimension to maintain aspect ratio
|
|
if required_width / original_aspect_ratio > required_height:
|
|
# Width is the limiting factor
|
|
new_width = int(required_width)
|
|
new_height = int(new_width / original_aspect_ratio)
|
|
else:
|
|
# Height is the limiting factor
|
|
new_height = int(required_height)
|
|
new_width = int(new_height * original_aspect_ratio)
|
|
|
|
# Create new image with transparent/white background
|
|
if has_transparency:
|
|
new_img = Image.new(
|
|
'RGBA', (new_width, new_height), (255, 255, 255, 0))
|
|
else:
|
|
new_img = Image.new(
|
|
'RGB', (new_width, new_height), (255, 255, 255))
|
|
|
|
# Calculate position to center the content
|
|
paste_x = (new_width - content_width) // 2
|
|
paste_y = (new_height - content_height) // 2
|
|
|
|
# Paste cropped content onto new image
|
|
if has_transparency:
|
|
new_img.paste(cropped, (paste_x, paste_y), cropped)
|
|
else:
|
|
new_img.paste(cropped, (paste_x, paste_y))
|
|
|
|
actual_content_ratio_w = content_width / new_width
|
|
actual_content_ratio_h = content_height / new_height
|
|
print(f" → Adjusting to {new_width}x{new_height} " +
|
|
f"(aspect ratio: {original_aspect_ratio:.2f}, " +
|
|
f"content: {actual_content_ratio_w*100:.1f}% x {actual_content_ratio_h*100:.1f}%)")
|
|
|
|
if not dry_run:
|
|
# Save the adjusted image with optimization
|
|
optimize_png_pillow(new_img, img_path, has_transparency)
|
|
|
|
# Get new file size after Pillow optimization
|
|
new_file_size = get_file_size(img_path)
|
|
|
|
# Optionally use pngquant for additional compression
|
|
if use_pngquant:
|
|
print(f" 🔧 Applying pngquant optimization...")
|
|
if optimize_png_with_pngquant(img_path, quality_range):
|
|
pngquant_size = get_file_size(img_path)
|
|
print(f" pngquant: {format_size(new_file_size)} → {format_size(pngquant_size)} " +
|
|
f"({(pngquant_size/new_file_size-1)*100:+.1f}%)")
|
|
new_file_size = pngquant_size
|
|
|
|
size_change_pct = (new_file_size / original_file_size - 1) * 100
|
|
print(f" ✓ Saved: {format_size(original_file_size)} → {format_size(new_file_size)} " +
|
|
f"({size_change_pct:+.1f}%)")
|
|
|
|
return {
|
|
'adjusted': True,
|
|
'original_size': original_file_size,
|
|
'new_size': new_file_size,
|
|
'size_saved': original_file_size - new_file_size
|
|
}
|
|
else:
|
|
print(f" ⚠️ Dry run - not saved")
|
|
return {
|
|
'adjusted': False,
|
|
'original_size': original_file_size,
|
|
'new_size': original_file_size,
|
|
'size_saved': 0
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f" ❌ Error processing {img_path}: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
|
|
def find_and_process_cover_images(base_path, target_ratio=0.84, dry_run=False, use_pngquant=False, quality_range="65-80", max_size=None):
|
|
"""
|
|
Find all *_cover.png images and process them.
|
|
|
|
Args:
|
|
base_path: Base directory to search
|
|
target_ratio: Target content to image ratio
|
|
dry_run: If True, don't save changes
|
|
use_pngquant: Use pngquant for additional compression
|
|
quality_range: Quality range for pngquant
|
|
max_size: Maximum dimension (width or height) in pixels
|
|
|
|
Returns:
|
|
Dict with statistics
|
|
"""
|
|
base_path = Path(base_path)
|
|
|
|
if not base_path.exists():
|
|
print(f"❌ Path does not exist: {base_path}")
|
|
return {'total': 0, 'adjusted': 0, 'skipped': 0, 'errors': 0,
|
|
'original_total_size': 0, 'new_total_size': 0, 'total_saved': 0}
|
|
|
|
# Find all *_cover.png files
|
|
cover_images = list(base_path.rglob('*_cover.png'))
|
|
|
|
if not cover_images:
|
|
print(f"⚠️ No *_cover.png files found in {base_path}")
|
|
return {'total': 0, 'adjusted': 0, 'skipped': 0, 'errors': 0,
|
|
'original_total_size': 0, 'new_total_size': 0, 'total_saved': 0}
|
|
|
|
print(f"🔍 Found {len(cover_images)} cover image(s) in {base_path}")
|
|
|
|
if use_pngquant:
|
|
if check_pngquant_available():
|
|
print(f"✓ pngquant is available and will be used")
|
|
else:
|
|
print(f"⚠️ pngquant not found in PATH, will use Pillow optimization only")
|
|
print(
|
|
f" Install: brew install pngquant (macOS) or apt install pngquant (Linux)")
|
|
use_pngquant = False
|
|
|
|
stats = {
|
|
'total': len(cover_images),
|
|
'adjusted': 0,
|
|
'skipped': 0,
|
|
'errors': 0,
|
|
'original_total_size': 0,
|
|
'new_total_size': 0,
|
|
'total_saved': 0
|
|
}
|
|
|
|
for img_path in cover_images:
|
|
try:
|
|
result = adjust_image_margins(
|
|
img_path, target_ratio, dry_run, use_pngquant, quality_range, max_size)
|
|
if result is None:
|
|
stats['errors'] += 1
|
|
elif result.get('adjusted'):
|
|
stats['adjusted'] += 1
|
|
stats['original_total_size'] += result['original_size']
|
|
stats['new_total_size'] += result['new_size']
|
|
stats['total_saved'] += result['size_saved']
|
|
else:
|
|
stats['skipped'] += 1
|
|
stats['original_total_size'] += result['original_size']
|
|
stats['new_total_size'] += result['original_size']
|
|
except Exception as e:
|
|
print(f"❌ Error processing {img_path}: {e}")
|
|
stats['errors'] += 1
|
|
|
|
return stats
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Optimize cover images: \n'
|
|
'1. Scale the image to maintain proper margins around the content. \n'
|
|
'2. Reduce the image size using pngquant. \n'
|
|
'3. Resize the image to fit within the maximum allowed dimensions.',
|
|
epilog='Examples:\n'
|
|
' %(prog)s --dry-run\n'
|
|
' %(prog)s --optimize\n'
|
|
' %(prog)s --optimize --quality 70-85\n'
|
|
' %(prog)s --vendor Custom\n'
|
|
' %(prog)s --vendor Custom --optimize\n'
|
|
' %(prog)s --max-size 200\n'
|
|
' %(prog)s --no-resize\n'
|
|
' %(prog)s --path ./custom/path --ratio 0.80\n'
|
|
'\n'
|
|
'Dependencies:\n'
|
|
' Required: pip3 install Pillow\n'
|
|
' Optional (for --optimize):\n'
|
|
' macOS: brew install pngquant\n'
|
|
' Linux: sudo apt install pngquant\n'
|
|
' Arch: sudo pacman -S pngquant\n'
|
|
' Windows: choco install pngquant or download from https://pngquant.org/',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
parser.add_argument(
|
|
'--path',
|
|
default='./resources/profiles',
|
|
help='Base path to search for cover images (default: ./resources/profiles)'
|
|
)
|
|
parser.add_argument(
|
|
'--vendor',
|
|
type=str,
|
|
help='Process only a specific vendor subfolder (e.g., "Custom")'
|
|
)
|
|
parser.add_argument(
|
|
'--ratio',
|
|
type=float,
|
|
default=1,
|
|
help='Target content to image ratio (default: 1 = 100%%)'
|
|
)
|
|
parser.add_argument(
|
|
'--dry-run',
|
|
action='store_true',
|
|
help='Preview changes without saving'
|
|
)
|
|
parser.add_argument(
|
|
'--optimize',
|
|
action='store_true',
|
|
help='Use pngquant for additional compression (must be installed)'
|
|
)
|
|
parser.add_argument(
|
|
'--quality',
|
|
default='65-80',
|
|
help='Quality range for pngquant (default: 65-80). Lower = smaller files'
|
|
)
|
|
parser.add_argument(
|
|
'--max-size',
|
|
type=int,
|
|
default=240,
|
|
help='Maximum image dimension in pixels (default: 240). Images larger than this will be resized'
|
|
)
|
|
parser.add_argument(
|
|
'--no-resize',
|
|
action='store_true',
|
|
help='Disable automatic resizing of large images'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
print("=" * 70)
|
|
print("Cover Image Margin Adjuster & Optimizer")
|
|
print("=" * 70)
|
|
|
|
if args.dry_run:
|
|
print("⚠️ DRY RUN MODE - No changes will be saved\n")
|
|
|
|
# Determine the search path
|
|
search_path = args.path
|
|
if args.vendor:
|
|
search_path = os.path.join(args.path, args.vendor)
|
|
print(f"🎯 Processing vendor: {args.vendor}")
|
|
print(f" Path: {search_path}")
|
|
|
|
# Check if vendor path exists
|
|
if not os.path.exists(search_path):
|
|
print(f"❌ Error: Vendor path does not exist: {search_path}")
|
|
print(f"\nAvailable vendors in {args.path}:")
|
|
try:
|
|
vendors = [d for d in os.listdir(args.path)
|
|
if os.path.isdir(os.path.join(args.path, d)) and not d.startswith('.')]
|
|
for vendor in sorted(vendors):
|
|
print(f" - {vendor}")
|
|
except Exception:
|
|
pass
|
|
return 1
|
|
print()
|
|
|
|
# Determine max size (None if --no-resize is specified)
|
|
max_size = None if args.no_resize else args.max_size
|
|
|
|
if max_size:
|
|
print(f"📏 Images will be resized to max {max_size}px if larger\n")
|
|
|
|
stats = find_and_process_cover_images(
|
|
search_path,
|
|
args.ratio,
|
|
args.dry_run,
|
|
args.optimize,
|
|
args.quality,
|
|
max_size
|
|
)
|
|
|
|
print("\n" + "=" * 70)
|
|
print("Summary:")
|
|
print(f" Total images: {stats['total']}")
|
|
print(f" Adjusted: {stats['adjusted']}")
|
|
print(f" Already correct: {stats['skipped']}")
|
|
print(f" Errors: {stats['errors']}")
|
|
|
|
if stats['adjusted'] > 0:
|
|
print(f"\n File Size:")
|
|
print(f" Original: {format_size(stats['original_total_size'])}")
|
|
print(f" New: {format_size(stats['new_total_size'])}")
|
|
if stats['total_saved'] > 0:
|
|
saved_pct = (stats['total_saved'] /
|
|
stats['original_total_size']) * 100
|
|
print(
|
|
f" Saved: {format_size(stats['total_saved'])} ({saved_pct:.1f}%)")
|
|
elif stats['total_saved'] < 0:
|
|
increased_pct = (-stats['total_saved'] /
|
|
stats['original_total_size']) * 100
|
|
print(
|
|
f" Increased: {format_size(-stats['total_saved'])} (+{increased_pct:.1f}%)")
|
|
|
|
print("=" * 70)
|
|
|
|
return 0 if stats['errors'] == 0 else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|