Skip to content

Instantly share code, notes, and snippets.

@yuwash
Created December 14, 2025 12:03
Show Gist options
  • Select an option

  • Save yuwash/092e47917956379d2fee2773e0f48e70 to your computer and use it in GitHub Desktop.

Select an option

Save yuwash/092e47917956379d2fee2773e0f48e70 to your computer and use it in GitHub Desktop.
Image batch processing (rename, remove background, crop, paste into pptx)
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()
[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