Created
December 14, 2025 12:03
-
-
Save yuwash/092e47917956379d2fee2773e0f48e70 to your computer and use it in GitHub Desktop.
Image batch processing (rename, remove background, crop, paste into pptx)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import argparse | |
| import os | |
| import sys | |
| import re | |
| import shutil | |
| from PIL import Image, ImageDraw | |
| from pptx import Presentation | |
| from pptx.util import Inches, Pt | |
| from pptx.enum.shapes import MSO_SHAPE | |
| from pptx.dml.color import RGBColor | |
| def natural_sort_key(s): | |
| """ | |
| Key function for natural sorting. | |
| Extracts numbers from strings and converts them to integers for sorting. | |
| Handles filenames with numbers and strings. | |
| Raises ValueError if no number is found in the filename. | |
| """ | |
| # Try to find a number in the filename | |
| match = re.search(r'\d+', s) | |
| if match: | |
| # If a number is found, return a tuple: (0, integer_value, original_string) | |
| # The 0 ensures numbers are sorted before strings if they appear at the start | |
| return (0, int(match.group(0)), s) | |
| else: | |
| # If no number is found, raise a ValueError | |
| raise ValueError(f"Filename '{s}' does not contain a number for natural sorting.") | |
| def rename_files(directory, prefix): | |
| """Renames all .png files in the specified directory to {prefix}-XX.png format using natural sorting.""" | |
| try: | |
| # List files in the specified directory | |
| files_to_rename = sorted([f for f in os.listdir(directory) if f.endswith('.png')], key=natural_sort_key) | |
| except ValueError as e: | |
| print(f"Error during sorting: {e}", file=sys.stderr) | |
| sys.exit(1) | |
| except FileNotFoundError: | |
| print(f"Error: Directory '{directory}' not found.", file=sys.stderr) | |
| sys.exit(1) | |
| for i, filename in enumerate(files_to_rename): | |
| # Construct the full path for renaming | |
| old_filepath = os.path.join(directory, filename) | |
| new_filename = f"{prefix}-{i+1:02d}.png" | |
| new_filepath = os.path.join(directory, new_filename) | |
| try: | |
| os.rename(old_filepath, new_filepath) | |
| print(f"Renamed '{filename}' to '{new_filename}'") | |
| except OSError as e: | |
| print(f"Error renaming '{filename}': {e}", file=sys.stderr) | |
| def remove_background(directory, tolerance=30, print_color=False): | |
| """ | |
| Removes the background from all .png images in the specified directory. | |
| Creates an 'rmbg' subdirectory and saves the processed images there. | |
| The background color is determined by the top-left pixel. | |
| If print_color is True, it prints the hex code of the background color for each file. | |
| """ | |
| rmbg_dir = os.path.join(directory, "rmbg") | |
| if not print_color: | |
| os.makedirs(rmbg_dir, exist_ok=True) | |
| for filename in os.listdir(directory): | |
| if filename.lower().endswith('.png'): | |
| filepath = os.path.join(directory, filename) | |
| try: | |
| img = Image.open(filepath).convert("RGBA") | |
| # Get the background color from the top-left pixel | |
| bg_color = img.getpixel((0, 0)) | |
| hex_color = "#{:02x}{:02x}{:02x}".format(bg_color[0], bg_color[1], bg_color[2]) | |
| if print_color: | |
| print(f"{filename}: {hex_color}") | |
| continue # Skip background removal if just printing color | |
| # Create a new image with an alpha channel | |
| new_img = Image.new("RGBA", img.size, (255, 255, 255, 0)) | |
| # Iterate over each pixel | |
| for x in range(img.width): | |
| for y in range(img.height): | |
| pixel_color = img.getpixel((x, y)) | |
| # Check if the pixel color is close to the background color | |
| # Using a simple Euclidean distance for color difference | |
| color_diff = sum([(a - b) ** 2 for a, b in zip(pixel_color[:3], bg_color[:3])]) ** 0.5 | |
| if color_diff > tolerance: | |
| new_img.putpixel((x, y), pixel_color) | |
| new_filepath = os.path.join(rmbg_dir, filename) | |
| new_img.save(new_filepath) | |
| print(f"Processed '{filename}', saved to '{os.path.join(rmbg_dir, filename)}'") | |
| except FileNotFoundError: | |
| print(f"Error: File '{filepath}' not found.", file=sys.stderr) | |
| except Exception as e: | |
| print(f"Error processing '{filename}': {e}", file=sys.stderr) | |
| def crop_image_from_center(directory): | |
| """ | |
| Crops .png images from the center downwards to the last non-transparent row. | |
| It finds the first row from the center downwards that is *entirely* transparent, | |
| and crops the image up to the row *before* that. | |
| Creates a 'crop' subdirectory and saves the processed images there. | |
| """ | |
| crop_dir = os.path.join(directory, "crop") | |
| os.makedirs(crop_dir, exist_ok=True) | |
| for filename in os.listdir(directory): | |
| if filename.lower().endswith('.png'): | |
| filepath = os.path.join(directory, filename) | |
| try: | |
| img = Image.open(filepath).convert("RGBA") | |
| width, height = img.size | |
| center_y = height // 2 | |
| # Find the first row from the center downwards that is *entirely* transparent. | |
| # We want to crop *up to* the row *before* this fully transparent row. | |
| crop_to_y = height # Default to full height if no fully transparent rows found from center downwards | |
| # Iterate downwards from the center | |
| for y in range(center_y, height): | |
| is_row_fully_transparent = True | |
| for x in range(width): | |
| pixel_alpha = img.getpixel((x, y))[3] # Get alpha channel value | |
| if pixel_alpha > 0: # If any pixel in the row is not fully transparent | |
| is_row_fully_transparent = False | |
| break | |
| if is_row_fully_transparent: | |
| # Found the first fully transparent row. Crop up to the row *before* it. | |
| crop_to_y = y | |
| break | |
| # If crop_to_y is still height, it means no fully transparent rows were found from center downwards. | |
| # This implies the bottom part of the image has content all the way down. | |
| # In this scenario, we should crop to the full height of the image. | |
| # However, the original request implies cropping *up to* where transparency begins. | |
| # If no transparency is found, we should keep the whole image. | |
| # Let's refine: if no fully transparent row is found, we should find the last non-transparent row. | |
| # The previous logic was to find the last non-transparent row. This new logic is to find the first fully transparent row. | |
| # If no fully transparent row is found, we should crop to the full height. | |
| # Re-evaluating the requirement: "until it reaches a height where the complete row is transparent, then crops the image up to the position where we still had a non-transparent pixel." | |
| # This means we find the first fully transparent row (let's call its index `first_transparent_row_idx`). | |
| # Then we crop to `first_transparent_row_idx`. | |
| # If no fully transparent row is found, we should crop to the full height. | |
| # The loop above correctly finds `crop_to_y` as the index of the first fully transparent row. | |
| # If `crop_to_y` remains `height`, it means no fully transparent row was found. | |
| # In that case, we should crop to the full height. | |
| # Ensure we don't crop to a height less than 1 pixel if there's content | |
| if crop_to_y > 0: | |
| cropped_img = img.crop((0, 0, width, crop_to_y)) | |
| new_filepath = os.path.join(crop_dir, filename) | |
| cropped_img.save(new_filepath) | |
| print(f"Cropped '{filename}', saved to '{os.path.join(crop_dir, filename)}'") | |
| elif width > 0 and height > 0: # If crop_to_y is 0, it means the first row from center is transparent. | |
| # If the image is not empty and crop_to_y is 0, it means the entire image from center up is transparent. | |
| # We should still save a minimal image if possible, or indicate it's fully transparent. | |
| # For now, let's save a 1-pixel height image if width > 0. | |
| cropped_img = img.crop((0, 0, width, 1)) | |
| new_filepath = os.path.join(crop_dir, filename) | |
| cropped_img.save(new_filepath) | |
| print(f"Cropped '{filename}' to minimal height (1px), saved to '{os.path.join(crop_dir, filename)}'") | |
| else: | |
| print(f"Warning: Image '{filename}' is empty or has zero dimensions. Skipping crop.") | |
| except FileNotFoundError: | |
| print(f"Error: File '{filepath}' not found.", file=sys.stderr) | |
| except Exception as e: | |
| print(f"Error processing '{filename}': {e}", file=sys.stderr) | |
| def create_slideshow(directory, image_height_ratio=0.33): | |
| """ | |
| Creates a PowerPoint presentation (slides.pptx) with each image from the directory on a new slide. | |
| Slide size is 1080x1080 px. Background color is #8aba19. | |
| Images are resized to a specified percentage of slide height, maintaining aspect ratio, and aligned top-left. | |
| """ | |
| prs = Presentation() | |
| # Set slide size to 1080x1080 pixels | |
| width = 1080 | |
| height = 1080 | |
| prs.slide_width = Inches(width / 96) # Default DPI is 96 | |
| prs.slide_height = Inches(height / 96) | |
| # Define the background color #8aba19 | |
| background_color = RGBColor(0x8A, 0xBA, 0x19) | |
| image_files = sorted([f for f in os.listdir(directory) if f.lower().endswith(('.png', '.jpg', '.jpeg'))], key=natural_sort_key) | |
| for filename in image_files: | |
| filepath = os.path.join(directory, filename) | |
| # Add a blank slide layout | |
| blank_slide_layout = prs.slide_layouts[5] # Layout 5 is typically blank | |
| slide = prs.slides.add_slide(blank_slide_layout) | |
| # Set slide background color | |
| slide.background.fill.solid() | |
| slide.background.fill.fore_color.rgb = background_color | |
| try: | |
| img = Image.open(filepath) | |
| img_width, img_height = img.size | |
| # Calculate image dimensions for the slide | |
| # Image height should be image_height_ratio of slide height | |
| target_img_height = height * image_height_ratio | |
| # Calculate aspect ratio | |
| aspect_ratio = img_width / img_height | |
| # Calculate new width based on target height and aspect ratio | |
| target_img_width = target_img_height * aspect_ratio | |
| # Position the image at the top-left corner | |
| left = Inches(0) | |
| top = Inches(0) | |
| # Add the image to the slide | |
| slide.shapes.add_picture(filepath, left, top, width=Inches(target_img_width / 96), height=Inches(target_img_height / 96)) | |
| print(f"Added '{filename}' to a new slide.") | |
| except FileNotFoundError: | |
| print(f"Error: Image file '{filepath}' not found.", file=sys.stderr) | |
| except Exception as e: | |
| print(f"Error processing image '{filename}': {e}", file=sys.stderr) | |
| # Save the presentation | |
| output_pptx_path = os.path.join(directory, "slides.pptx") | |
| try: | |
| prs.save(output_pptx_path) | |
| print(f"Presentation saved successfully to '{output_pptx_path}'") | |
| except Exception as e: | |
| print(f"Error saving presentation: {e}", file=sys.stderr) | |
| def main(): | |
| parser = argparse.ArgumentParser(description="A simple script with subcommands.") | |
| # Add positional argument for the directory | |
| parser.add_argument('directory', help='The directory containing the files to process.') | |
| subparsers = parser.add_subparsers(dest='command', help='Available commands') | |
| # Rename subcommand | |
| rename_parser = subparsers.add_parser('rename', help='Rename all .png files to {prefix}-XX.png format') | |
| rename_parser.add_argument('--prefix', '-p', help='Prefix for the new file names.') | |
| # Remove background subcommand | |
| rmbg_parser = subparsers.add_parser('rmbg', help='Remove background from .png images, making them transparent') | |
| rmbg_parser.add_argument('--tolerance', type=int, default=30, | |
| help='Tolerance for background color matching (0-255). Default is 30.') | |
| rmbg_parser.add_argument('--print-color', action='store_true', | |
| help='Print the hex code of the background color for each file and exit.') | |
| # Crop subcommand | |
| crop_parser = subparsers.add_parser('crop', help='Crop images from the center downwards to the last non-transparent row') | |
| # Slides subcommand | |
| slides_parser = subparsers.add_parser('slides', help='Create a PowerPoint presentation with images, one per slide.') | |
| slides_parser.add_argument('--image-height-ratio', type=float, default=0.33, | |
| help='The desired height of the image on the slide as a ratio of the slide height (default: 0.33).') | |
| args = parser.parse_args() | |
| if args.command == 'rename': | |
| rename_files(args.directory, args.prefix) | |
| elif args.command == 'rmbg': | |
| remove_background(args.directory, args.tolerance, args.print_color) | |
| elif args.command == 'crop': | |
| crop_image_from_center(args.directory) | |
| elif args.command == 'slides': | |
| create_slideshow(args.directory, args.image_height_ratio) | |
| else: | |
| # If no command is specified, print help and exit | |
| parser.print_help() | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [project] | |
| name = "imagebatch" | |
| version = "0.1.0" | |
| description = "Image batch processing (rename, remove background, crop, paste into pptx)" | |
| requires-python = ">=3.12" | |
| dependencies = [ | |
| "pillow>=12.0.0", | |
| "python-pptx>=1.0.2", | |
| ] |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment