Loading...
No commits yet
Not committed History
Blame
optimize_figure.py • 9.7 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Automatic Figure Optimization for SciTex

This script automatically optimizes figures for publication by:
1. Analyzing image dimensions and aspect ratio
2. Resizing to optimal resolution for publication
3. Enhancing image quality if needed
4. Cropping excess whitespace

Usage:
  python optimize_figure.py --input <input_file> [--output <output_file>] [--dpi <dpi>] [--quality <quality>] [--max-width <pixels>] [--max-height <pixels>]

Options:
  --input FILE       Input image file (required)
  --output FILE      Output image file (default: same as input with _optimized suffix)
  --dpi INT          Target DPI (default: 300)
  --quality INT      JPEG quality (1-100, default: 90)
  --max-width INT    Maximum width in pixels (default: 2000)
  --max-height INT   Maximum height in pixels (default: 2000)
  --no-crop          Disable automatic cropping (default: crop enabled)
  --verbose          Enable verbose output
"""

import argparse
import logging
import os
import sys

from PIL import Image, ImageChops, ImageEnhance

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)


def parse_arguments():
    """Parse command line arguments."""
    parser = argparse.ArgumentParser(description="Optimize figures for publication")
    parser.add_argument("--input", required=True, help="Input image file")
    parser.add_argument("--output", help="Output image file")
    parser.add_argument("--dpi", type=int, default=300, help="Target DPI")
    parser.add_argument("--quality", type=int, default=90, help="JPEG quality (1-100)")
    parser.add_argument(
        "--max-width", type=int, default=2000, help="Maximum width in pixels"
    )
    parser.add_argument(
        "--max-height", type=int, default=2000, help="Maximum height in pixels"
    )
    parser.add_argument(
        "--no-crop", action="store_true", help="Disable automatic cropping"
    )
    parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
    return parser.parse_args()


def crop_whitespace(image, padding=10):
    """
    Crop excess whitespace from the image.

    Args:
        image: PIL Image object
        padding: Number of pixels to keep as padding around content

    Returns:
        PIL Image with whitespace removed
    """
    # Convert to grayscale for analysis
    bg = Image.new(image.mode, image.size, image.getpixel((0, 0)))
    diff = ImageChops.difference(image, bg)
    diff = ImageChops.add(diff, diff, 2.0, -100)
    bbox = diff.getbbox()

    if bbox:
        # Add padding
        bbox = (
            max(0, bbox[0] - padding),
            max(0, bbox[1] - padding),
            min(image.width, bbox[2] + padding),
            min(image.height, bbox[3] + padding),
        )
        return image.crop(bbox)
    return image


def compute_optimal_size(width, height, max_width, max_height, target_dpi=300):
    """
    Compute the optimal image size based on max dimensions and DPI.

    Args:
        width: Current width in pixels
        height: Current height in pixels
        max_width: Maximum allowed width in pixels
        max_height: Maximum allowed height in pixels
        target_dpi: Target DPI for the image

    Returns:
        Tuple of (new_width, new_height)
    """
    # Calculate aspect ratio
    aspect_ratio = width / height

    # First check if the image exceeds maximum dimensions
    if width > max_width or height > max_height:
        # Scale down to fit within max dimensions
        if width / max_width > height / max_height:
            # Width is the limiting factor
            new_width = max_width
            new_height = int(new_width / aspect_ratio)
        else:
            # Height is the limiting factor
            new_height = max_height
            new_width = int(new_height * aspect_ratio)
    else:
        # Image is within size limits, check if DPI is sufficient
        # For publication quality, typically want 300 DPI at print size
        # Assume 8 inch width for a typical publication
        publication_width_px = 8 * target_dpi
        if (
            width < publication_width_px * 0.8
        ):  # If image is less than 80% of desired resolution
            scale_factor = publication_width_px / width
            # Limit scaling to 2x to avoid artifacts
            scale_factor = min(scale_factor, 2.0)
            new_width = int(width * scale_factor)
            new_height = int(height * scale_factor)
        else:
            # Image is already good quality, no resizing needed
            new_width, new_height = width, height

    # Ensure dimensions are even numbers (helps with certain compression algorithms)
    new_width = (new_width // 2) * 2
    new_height = (new_height // 2) * 2

    return new_width, new_height


def enhance_image_quality(image):
    """
    Apply basic enhancement to improve image quality.

    Args:
        image: PIL Image object

    Returns:
        Enhanced PIL Image
    """
    # Convert to RGB if needed
    if image.mode != "RGB":
        image = image.convert("RGB")

    # Apply moderate contrast enhancement
    enhancer = ImageEnhance.Contrast(image)
    image = enhancer.enhance(1.1)

    # Apply moderate sharpening
    enhancer = ImageEnhance.Sharpness(image)
    image = enhancer.enhance(1.2)

    return image


def optimize_figure(
    input_path,
    output_path=None,
    target_dpi=300,
    quality=90,
    max_width=2000,
    max_height=2000,
    no_crop=False,
    verbose=False,
):
    """
    Optimize a figure for publication quality.

    Args:
        input_path: Path to input image
        output_path: Path to save optimized image (default: auto-generate)
        target_dpi: Target DPI for the image
        quality: JPEG quality (1-100)
        max_width: Maximum width in pixels
        max_height: Maximum height in pixels
        no_crop: If True, don't crop whitespace
        verbose: Enable verbose logging

    Returns:
        Path to the optimized image
    """
    if verbose:
        logger.setLevel(logging.DEBUG)

    # Generate output path if not provided
    if not output_path:
        name, ext = os.path.splitext(input_path)
        output_path = f"{name}_optimized{ext}"

    logger.info(f"Processing: {input_path}")
    logger.info(f"Output will be saved to: {output_path}")

    try:
        # Load the image
        img = Image.open(input_path)
        original_width, original_height = img.size
        logger.info(f"Original dimensions: {original_width}x{original_height} pixels")

        # Step 1: Crop excess whitespace if enabled
        if not no_crop:
            logger.debug("Cropping excess whitespace...")
            img = crop_whitespace(img)
            cropped_width, cropped_height = img.size
            logger.info(f"After cropping: {cropped_width}x{cropped_height} pixels")
            if original_width * original_height > 0:
                crop_percentage = 100 - (cropped_width * cropped_height * 100) / (
                    original_width * original_height
                )
                logger.info(f"Removed {crop_percentage:.1f}% of whitespace")

        # Step 2: Determine optimal size
        current_width, current_height = img.size
        new_width, new_height = compute_optimal_size(
            current_width, current_height, max_width, max_height, target_dpi
        )

        if new_width != current_width or new_height != current_height:
            logger.info(f"Resizing to {new_width}x{new_height} pixels")
            # Use high-quality resampling
            img = img.resize((new_width, new_height), Image.LANCZOS)
        else:
            logger.info("Image dimensions are already optimal")

        # Step 3: Enhance image quality
        logger.debug("Enhancing image quality...")
        img = enhance_image_quality(img)

        # Step 4: Save with appropriate settings
        logger.debug(f"Saving with quality={quality}...")

        # Determine format-specific options
        save_args = {}
        ext = os.path.splitext(output_path)[1].lower()

        if ext == ".jpg" or ext == ".jpeg":
            save_args = {"quality": quality, "optimize": True, "progressive": True}
        elif ext == ".png":
            save_args = {"optimize": True}
        elif ext == ".tif" or ext == ".tiff":
            save_args = {"compression": "tiff_lzw"}

        # Ensure the output directory exists
        os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)

        # Save the optimized image
        img.save(output_path, **save_args)

        logger.info("Optimization complete")
        logger.info(f"Final dimensions: {new_width}x{new_height} pixels")

        # Calculate file size reduction
        original_size = os.path.getsize(input_path)
        optimized_size = os.path.getsize(output_path)
        size_reduction = 100 - (optimized_size * 100 / original_size)
        logger.info(
            f"File size: {original_size / 1024:.1f}KB → {optimized_size / 1024:.1f}KB ({size_reduction:.1f}% reduction)"
        )

        return output_path

    except Exception as e:
        logger.error(f"Error processing image: {e}")
        return None


def main():
    """Main function."""
    args = parse_arguments()

    if not os.path.exists(args.input):
        logger.error(f"Input file not found: {args.input}")
        sys.exit(1)

    result = optimize_figure(
        args.input,
        args.output,
        args.dpi,
        args.quality,
        args.max_width,
        args.max_height,
        args.no_crop,
        args.verbose,
    )

    if result:
        logger.info(f"Successfully optimized image: {result}")
        sys.exit(0)
    else:
        logger.error("Failed to optimize image")
        sys.exit(1)


if __name__ == "__main__":
    main()