[pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
This commit is contained in:
pre-commit-ci[bot] 2025-06-27 03:42:25 +00:00
parent f33d985b40
commit f6e9d8a307
8 changed files with 959 additions and 738 deletions

View File

@ -1,33 +1,51 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
import json
from PIL import Image, ImageFilter, ImageEnhance, ImageDraw, ImageFont, ImageOps
from PIL.ExifTags import TAGS
import numpy as np
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps
from PIL.ExifTags import TAGS
try:
from PIL import ImageTk
import tkinter as tk
from tkinter import ttk
from PIL import ImageTk
GUI_AVAILABLE = True
except ImportError:
GUI_AVAILABLE = False
class PillowCLI:
"""Main CLI class for image processing operations"""
def __init__(self):
self.supported_formats = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp'}
self.supported_formats = {
".jpg",
".jpeg",
".png",
".bmp",
".gif",
".tiff",
".webp",
}
def validate_image_path(self, path):
"""Validate if the path is a valid image file"""
if not os.path.exists(path):
raise FileNotFoundError(f"Image file not found: {path}")
if not Path(path).suffix.lower() in self.supported_formats:
raise ValueError(f"Unsupported image format. Supported: {', '.join(self.supported_formats)}")
if Path(path).suffix.lower() not in self.supported_formats:
raise ValueError(
f"Unsupported image format. Supported: {', '.join(self.supported_formats)}"
)
return True
@ -39,18 +57,26 @@ class PillowCLI:
def save_image(self, image, output_path, quality=95):
"""Save image to file with specified quality"""
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else '.', exist_ok=True)
os.makedirs(
os.path.dirname(output_path) if os.path.dirname(output_path) else ".",
exist_ok=True,
)
# Convert RGBA to RGB if saving as JPEG
if Path(output_path).suffix.lower() in ['.jpg', '.jpeg'] and image.mode == 'RGBA':
background = Image.new('RGB', image.size, (255, 255, 255))
if (
Path(output_path).suffix.lower() in [".jpg", ".jpeg"]
and image.mode == "RGBA"
):
background = Image.new("RGB", image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1])
image = background
image.save(output_path, quality=quality, optimize=True)
print(f"Image saved to: {output_path}")
def resize_image(self, input_path, output_path, width=None, height=None, maintain_aspect=True):
def resize_image(
self, input_path, output_path, width=None, height=None, maintain_aspect=True
):
"""Resize image with optional aspect ratio preservation"""
image = self.load_image(input_path)
original_width, original_height = image.size
@ -75,35 +101,47 @@ class PillowCLI:
resized_image = image.resize(new_size, Image.Resampling.LANCZOS)
self.save_image(resized_image, output_path)
print(f"Resized from {original_width}x{original_height} to {new_size[0]}x{new_size[1]}")
print(
f"Resized from {original_width}x{original_height} to {new_size[0]}x{new_size[1]}"
)
def apply_filters(self, input_path, output_path, filter_type):
"""Apply various filters to the image"""
image = self.load_image(input_path)
filters = {
'blur': ImageFilter.BLUR,
'contour': ImageFilter.CONTOUR,
'detail': ImageFilter.DETAIL,
'edge_enhance': ImageFilter.EDGE_ENHANCE,
'edge_enhance_more': ImageFilter.EDGE_ENHANCE_MORE,
'emboss': ImageFilter.EMBOSS,
'find_edges': ImageFilter.FIND_EDGES,
'sharpen': ImageFilter.SHARPEN,
'smooth': ImageFilter.SMOOTH,
'smooth_more': ImageFilter.SMOOTH_MORE,
'gaussian_blur': ImageFilter.GaussianBlur(radius=2),
'unsharp_mask': ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3)
"blur": ImageFilter.BLUR,
"contour": ImageFilter.CONTOUR,
"detail": ImageFilter.DETAIL,
"edge_enhance": ImageFilter.EDGE_ENHANCE,
"edge_enhance_more": ImageFilter.EDGE_ENHANCE_MORE,
"emboss": ImageFilter.EMBOSS,
"find_edges": ImageFilter.FIND_EDGES,
"sharpen": ImageFilter.SHARPEN,
"smooth": ImageFilter.SMOOTH,
"smooth_more": ImageFilter.SMOOTH_MORE,
"gaussian_blur": ImageFilter.GaussianBlur(radius=2),
"unsharp_mask": ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3),
}
if filter_type not in filters:
raise ValueError(f"Unknown filter: {filter_type}. Available: {', '.join(filters.keys())}")
raise ValueError(
f"Unknown filter: {filter_type}. Available: {', '.join(filters.keys())}"
)
filtered_image = image.filter(filters[filter_type])
self.save_image(filtered_image, output_path)
print(f"Applied {filter_type} filter")
def adjust_image(self, input_path, output_path, brightness=1.0, contrast=1.0, saturation=1.0, sharpness=1.0):
def adjust_image(
self,
input_path,
output_path,
brightness=1.0,
contrast=1.0,
saturation=1.0,
sharpness=1.0,
):
"""Adjust image brightness, contrast, saturation, and sharpness"""
image = self.load_image(input_path)
@ -125,7 +163,9 @@ class PillowCLI:
image = enhancer.enhance(sharpness)
self.save_image(image, output_path)
print(f"Adjusted image - Brightness: {brightness}, Contrast: {contrast}, Saturation: {saturation}, Sharpness: {sharpness}")
print(
f"Adjusted image - Brightness: {brightness}, Contrast: {contrast}, Saturation: {saturation}, Sharpness: {sharpness}"
)
def crop_image(self, input_path, output_path, x, y, width, height):
"""Crop image to specified dimensions"""
@ -134,7 +174,9 @@ class PillowCLI:
# Make sure we're not trying to crop outside the image bounds
img_width, img_height = image.size
if x + width > img_width or y + height > img_height:
raise ValueError(f"Crop dimensions exceed image size ({img_width}x{img_height})")
raise ValueError(
f"Crop dimensions exceed image size ({img_width}x{img_height})"
)
cropped_image = image.crop((x, y, x + width, y + height))
self.save_image(cropped_image, output_path)
@ -143,17 +185,17 @@ class PillowCLI:
def rotate_image(self, input_path, output_path, angle, expand=True):
"""Rotate image by specified angle"""
image = self.load_image(input_path)
rotated_image = image.rotate(angle, expand=expand, fillcolor='white')
rotated_image = image.rotate(angle, expand=expand, fillcolor="white")
self.save_image(rotated_image, output_path)
print(f"Rotated image by {angle} degrees")
def flip_image(self, input_path, output_path, direction='horizontal'):
def flip_image(self, input_path, output_path, direction="horizontal"):
"""Flip image horizontally or vertically"""
image = self.load_image(input_path)
if direction == 'horizontal':
if direction == "horizontal":
flipped_image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
elif direction == 'vertical':
elif direction == "vertical":
flipped_image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
else:
raise ValueError("Direction must be 'horizontal' or 'vertical'")
@ -161,14 +203,16 @@ class PillowCLI:
self.save_image(flipped_image, output_path)
print(f"Flipped image {direction}ly")
def convert_format(self, input_path, output_path, format_type='PNG'):
def convert_format(self, input_path, output_path, format_type="PNG"):
"""Convert image to different format"""
image = self.load_image(input_path)
# Handle transparency for JPEG conversion
if format_type.upper() == 'JPEG' and image.mode in ('RGBA', 'LA'):
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
if format_type.upper() == "JPEG" and image.mode in ("RGBA", "LA"):
background = Image.new("RGB", image.size, (255, 255, 255))
background.paste(
image, mask=image.split()[-1] if image.mode == "RGBA" else None
)
image = background
self.save_image(image, output_path)
@ -181,12 +225,19 @@ class PillowCLI:
self.save_image(image, output_path)
print(f"Created thumbnail with size {size}")
def add_watermark(self, input_path, output_path, watermark_text, position='bottom-right', opacity=128):
def add_watermark(
self,
input_path,
output_path,
watermark_text,
position="bottom-right",
opacity=128,
):
"""Add text watermark to image"""
image = self.load_image(input_path)
# Create a transparent overlay
overlay = Image.new('RGBA', image.size, (255, 255, 255, 0))
overlay = Image.new("RGBA", image.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(overlay)
# Try to use a default font, fallback to default if not available
@ -203,24 +254,32 @@ class PillowCLI:
# Calculate position
img_width, img_height = image.size
positions = {
'top-left': (10, 10),
'top-right': (img_width - text_width - 10, 10),
'bottom-left': (10, img_height - text_height - 10),
'bottom-right': (img_width - text_width - 10, img_height - text_height - 10),
'center': ((img_width - text_width) // 2, (img_height - text_height) // 2)
"top-left": (10, 10),
"top-right": (img_width - text_width - 10, 10),
"bottom-left": (10, img_height - text_height - 10),
"bottom-right": (
img_width - text_width - 10,
img_height - text_height - 10,
),
"center": ((img_width - text_width) // 2, (img_height - text_height) // 2),
}
if position not in positions:
raise ValueError(f"Invalid position. Available: {', '.join(positions.keys())}")
raise ValueError(
f"Invalid position. Available: {', '.join(positions.keys())}"
)
x, y = positions[position]
# Draw text with semi-transparent background
draw.rectangle([x-5, y-5, x+text_width+5, y+text_height+5], fill=(0, 0, 0, opacity//2))
draw.rectangle(
[x - 5, y - 5, x + text_width + 5, y + text_height + 5],
fill=(0, 0, 0, opacity // 2),
)
draw.text((x, y), watermark_text, font=font, fill=(255, 255, 255, opacity))
# Composite the overlay with the original image
watermarked_image = Image.alpha_composite(image.convert('RGBA'), overlay)
watermarked_image = Image.alpha_composite(image.convert("RGBA"), overlay)
self.save_image(watermarked_image, output_path)
print(f"Added watermark '{watermark_text}' at {position}")
@ -229,24 +288,24 @@ class PillowCLI:
image = self.load_image(input_path)
metadata = {
'filename': os.path.basename(input_path),
'format': image.format,
'mode': image.mode,
'size': image.size,
'width': image.width,
'height': image.height,
"filename": os.path.basename(input_path),
"format": image.format,
"mode": image.mode,
"size": image.size,
"width": image.width,
"height": image.height,
}
# Extract EXIF data if available
exif_data = {}
if hasattr(image, '_getexif') and image._getexif():
if hasattr(image, "_getexif") and image._getexif():
exif = image._getexif()
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
exif_data[tag] = value
if exif_data:
metadata['exif'] = exif_data
metadata["exif"] = exif_data
print(json.dumps(metadata, indent=2, default=str))
return metadata
@ -277,7 +336,7 @@ class PillowCLI:
collage_height = rows * max_height + (rows + 1) * padding
# Create collage
collage = Image.new('RGB', (collage_width, collage_height), 'white')
collage = Image.new("RGB", (collage_width, collage_height), "white")
for i, img in enumerate(resized_images):
row = i // cols
@ -289,11 +348,11 @@ class PillowCLI:
self.save_image(collage, output_path)
print(f"Created collage with {len(images)} images ({rows}x{cols} grid)")
def apply_artistic_effects(self, input_path, output_path, effect='sepia'):
def apply_artistic_effects(self, input_path, output_path, effect="sepia"):
"""Apply artistic effects to the image"""
image = self.load_image(input_path).convert('RGB')
image = self.load_image(input_path).convert("RGB")
if effect == 'sepia':
if effect == "sepia":
# Convert to sepia
pixels = image.load()
for y in range(image.height):
@ -304,20 +363,22 @@ class PillowCLI:
tb = int(0.272 * r + 0.534 * g + 0.131 * b)
pixels[x, y] = (min(255, tr), min(255, tg), min(255, tb))
elif effect == 'grayscale':
elif effect == "grayscale":
image = ImageOps.grayscale(image)
elif effect == 'invert':
elif effect == "invert":
image = ImageOps.invert(image)
elif effect == 'posterize':
elif effect == "posterize":
image = ImageOps.posterize(image, 4)
elif effect == 'solarize':
elif effect == "solarize":
image = ImageOps.solarize(image, threshold=128)
else:
raise ValueError(f"Unknown effect: {effect}. Available: sepia, grayscale, invert, posterize, solarize")
raise ValueError(
f"Unknown effect: {effect}. Available: sepia, grayscale, invert, posterize, solarize"
)
self.save_image(image, output_path)
print(f"Applied {effect} effect")
@ -349,15 +410,17 @@ class PillowCLI:
output_file = output_path / image_file.name
print(f"[{i}/{len(image_files)}] Processing: {image_file.name}")
if operation == 'resize':
if operation == "resize":
self.resize_image(str(image_file), str(output_file), **kwargs)
elif operation == 'filter':
elif operation == "filter":
self.apply_filters(str(image_file), str(output_file), **kwargs)
elif operation == 'adjust':
elif operation == "adjust":
self.adjust_image(str(image_file), str(output_file), **kwargs)
elif operation == 'effect':
self.apply_artistic_effects(str(image_file), str(output_file), **kwargs)
elif operation == 'thumbnail':
elif operation == "effect":
self.apply_artistic_effects(
str(image_file), str(output_file), **kwargs
)
elif operation == "thumbnail":
self.create_thumbnail(str(image_file), str(output_file), **kwargs)
else:
print(f"Unknown batch operation: {operation}")
@ -387,11 +450,11 @@ class PillowCLI:
if operation == "Resize":
self.resize_image(input_file, output_file, width=300, height=300)
elif operation == "Blur":
self.apply_filters(input_file, output_file, 'blur')
self.apply_filters(input_file, output_file, "blur")
elif operation == "Sepia":
self.apply_artistic_effects(input_file, output_file, 'sepia')
self.apply_artistic_effects(input_file, output_file, "sepia")
elif operation == "Grayscale":
self.apply_artistic_effects(input_file, output_file, 'grayscale')
self.apply_artistic_effects(input_file, output_file, "grayscale")
status_var.set(f"Success! Processed image saved to {output_file}")
except Exception as e:
@ -415,8 +478,11 @@ class PillowCLI:
ttk.Entry(root, textvariable=output_var, width=60).pack(pady=5)
ttk.Label(root, text="Operation:").pack(pady=5)
ttk.Combobox(root, textvariable=operation_var,
values=["Resize", "Blur", "Sepia", "Grayscale"]).pack(pady=5)
ttk.Combobox(
root,
textvariable=operation_var,
values=["Resize", "Blur", "Sepia", "Grayscale"],
).pack(pady=5)
ttk.Button(root, text="Process Image", command=process_image).pack(pady=20)
@ -427,7 +493,7 @@ class PillowCLI:
def analyze_histogram(self, input_path, output_path=None):
"""Analyze and optionally save image histogram"""
image = self.load_image(input_path).convert('RGB')
image = self.load_image(input_path).convert("RGB")
# Calculate histogram for each channel
hist_r = image.histogram()[0:256]
@ -435,10 +501,10 @@ class PillowCLI:
hist_b = image.histogram()[512:768]
histogram_data = {
'red_channel': hist_r,
'green_channel': hist_g,
'blue_channel': hist_b,
'total_pixels': image.width * image.height
"red_channel": hist_r,
"green_channel": hist_g,
"blue_channel": hist_b,
"total_pixels": image.width * image.height,
}
print(f"Image: {os.path.basename(input_path)}")
@ -453,7 +519,7 @@ class PillowCLI:
print(f"Average RGB values: R={avg_r:.1f}, G={avg_g:.1f}, B={avg_b:.1f}")
if output_path:
with open(output_path, 'w') as f:
with open(output_path, "w") as f:
json.dump(histogram_data, f, indent=2)
print(f"Histogram data saved to: {output_path}")
@ -461,7 +527,7 @@ class PillowCLI:
def extract_color_palette(self, input_path, num_colors=5):
"""Extract dominant colors from image"""
image = self.load_image(input_path).convert('RGB')
image = self.load_image(input_path).convert("RGB")
# Resize image for faster processing
image = image.resize((150, 150))
@ -473,13 +539,14 @@ class PillowCLI:
# Use k-means clustering to find dominant colors
try:
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=num_colors, random_state=42, n_init=10)
kmeans.fit(pixels)
colors = kmeans.cluster_centers_.astype(int)
print(f"Dominant colors in {os.path.basename(input_path)}:")
for i, color in enumerate(colors, 1):
hex_color = '#{:02x}{:02x}{:02x}'.format(color[0], color[1], color[2])
hex_color = f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}"
print(f" {i}. RGB({color[0]}, {color[1]}, {color[2]}) - {hex_color}")
return colors.tolist()
@ -492,33 +559,39 @@ class PillowCLI:
unique_colors[color] = unique_colors.get(color, 0) + 1
# Get most common colors
sorted_colors = sorted(unique_colors.items(), key=lambda x: x[1], reverse=True)
sorted_colors = sorted(
unique_colors.items(), key=lambda x: x[1], reverse=True
)
top_colors = [list(color[0]) for color in sorted_colors[:num_colors]]
print(f"Most common colors in {os.path.basename(input_path)}:")
for i, color in enumerate(top_colors, 1):
hex_color = '#{:02x}{:02x}{:02x}'.format(color[0], color[1], color[2])
hex_color = f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}"
print(f" {i}. RGB({color[0]}, {color[1]}, {color[2]}) - {hex_color}")
return top_colors
def create_border(self, input_path, output_path, border_width=10, border_color='black'):
def create_border(
self, input_path, output_path, border_width=10, border_color="black"
):
"""Add border around image"""
image = self.load_image(input_path)
# Parse border color
if isinstance(border_color, str):
if border_color.startswith('#'):
if border_color.startswith("#"):
# Hex color
border_color = tuple(int(border_color[i:i+2], 16) for i in (1, 3, 5))
elif border_color in ['black', 'white', 'red', 'green', 'blue']:
border_color = tuple(
int(border_color[i : i + 2], 16) for i in (1, 3, 5)
)
elif border_color in ["black", "white", "red", "green", "blue"]:
# Named colors
color_map = {
'black': (0, 0, 0),
'white': (255, 255, 255),
'red': (255, 0, 0),
'green': (0, 255, 0),
'blue': (0, 0, 255)
"black": (0, 0, 0),
"white": (255, 255, 255),
"red": (255, 0, 0),
"green": (0, 255, 0),
"blue": (0, 0, 255),
}
border_color = color_map[border_color]
@ -526,10 +599,12 @@ class PillowCLI:
self.save_image(bordered_image, output_path)
print(f"Added {border_width}px border with color {border_color}")
def create_composite(self, background_path, overlay_path, output_path, position=(0, 0), opacity=1.0):
def create_composite(
self, background_path, overlay_path, output_path, position=(0, 0), opacity=1.0
):
"""Composite two images together"""
background = self.load_image(background_path).convert('RGBA')
overlay = self.load_image(overlay_path).convert('RGBA')
background = self.load_image(background_path).convert("RGBA")
overlay = self.load_image(overlay_path).convert("RGBA")
# Adjust overlay opacity
if opacity < 1.0:
@ -537,7 +612,7 @@ class PillowCLI:
overlay.putalpha(int(255 * opacity))
# Create composite
composite = Image.new('RGBA', background.size, (0, 0, 0, 0))
composite = Image.new("RGBA", background.size, (0, 0, 0, 0))
composite.paste(background, (0, 0))
composite.paste(overlay, position, overlay)
@ -546,11 +621,11 @@ class PillowCLI:
def apply_vignette(self, input_path, output_path, strength=0.5):
"""Apply vignette effect to image"""
image = self.load_image(input_path).convert('RGBA')
image = self.load_image(input_path).convert("RGBA")
width, height = image.size
# Create vignette mask
mask = Image.new('L', (width, height), 0)
mask = Image.new("L", (width, height), 0)
mask_draw = ImageDraw.Draw(mask)
# Calculate center and radii
@ -562,18 +637,27 @@ class PillowCLI:
for i in range(steps):
radius = max_radius * (i / steps)
alpha = int(255 * (1 - strength * (i / steps)))
mask_draw.ellipse([center_x - radius, center_y - radius,
center_x + radius, center_y + radius], fill=alpha)
mask_draw.ellipse(
[
center_x - radius,
center_y - radius,
center_x + radius,
center_y + radius,
],
fill=alpha,
)
# Apply vignette
vignette_overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0))
vignette_overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0))
vignette_overlay.putalpha(mask)
result = Image.alpha_composite(image, vignette_overlay)
self.save_image(result, output_path)
print(f"Applied vignette effect with strength {strength}")
def create_contact_sheet(self, input_paths, output_path, sheet_width=1200, margin=10):
def create_contact_sheet(
self, input_paths, output_path, sheet_width=1200, margin=10
):
"""Create a contact sheet from multiple images"""
if not input_paths:
raise ValueError("No input images provided")
@ -589,7 +673,7 @@ class PillowCLI:
# Create contact sheet
sheet_height = rows * thumb_height + margin * (rows + 1)
contact_sheet = Image.new('RGB', (sheet_width, sheet_height), 'white')
contact_sheet = Image.new("RGB", (sheet_width, sheet_height), "white")
for i, img in enumerate(images):
# Create thumbnail
@ -614,7 +698,7 @@ class PillowCLI:
def split_channels(self, input_path, output_dir):
"""Split image into separate color channels"""
image = self.load_image(input_path).convert('RGB')
image = self.load_image(input_path).convert("RGB")
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
@ -629,9 +713,15 @@ class PillowCLI:
b_path = output_path / f"{base_name}_blue.png"
# Convert to RGB for saving (grayscale channels)
r_img = Image.merge('RGB', [r, Image.new('L', image.size, 0), Image.new('L', image.size, 0)])
g_img = Image.merge('RGB', [Image.new('L', image.size, 0), g, Image.new('L', image.size, 0)])
b_img = Image.merge('RGB', [Image.new('L', image.size, 0), Image.new('L', image.size, 0), b])
r_img = Image.merge(
"RGB", [r, Image.new("L", image.size, 0), Image.new("L", image.size, 0)]
)
g_img = Image.merge(
"RGB", [Image.new("L", image.size, 0), g, Image.new("L", image.size, 0)]
)
b_img = Image.merge(
"RGB", [Image.new("L", image.size, 0), Image.new("L", image.size, 0), b]
)
self.save_image(r_img, str(r_path))
self.save_image(g_img, str(g_path))
@ -640,113 +730,173 @@ class PillowCLI:
print(f"Split channels saved to {output_dir}")
return [str(r_path), str(g_path), str(b_path)]
def main():
"""Main function to handle command line arguments"""
parser = argparse.ArgumentParser(description="Pillow CLI - Comprehensive Image Processing Tool")
subparsers = parser.add_subparsers(dest='command', help='Available commands')
parser = argparse.ArgumentParser(
description="Pillow CLI - Comprehensive Image Processing Tool"
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# Resize command
resize_parser = subparsers.add_parser('resize', help='Resize image')
resize_parser.add_argument('input', help='Input image path')
resize_parser.add_argument('output', help='Output image path')
resize_parser.add_argument('--width', type=int, help='Target width')
resize_parser.add_argument('--height', type=int, help='Target height')
resize_parser.add_argument('--no-aspect', action='store_true', help='Don\'t maintain aspect ratio')
resize_parser = subparsers.add_parser("resize", help="Resize image")
resize_parser.add_argument("input", help="Input image path")
resize_parser.add_argument("output", help="Output image path")
resize_parser.add_argument("--width", type=int, help="Target width")
resize_parser.add_argument("--height", type=int, help="Target height")
resize_parser.add_argument(
"--no-aspect", action="store_true", help="Don't maintain aspect ratio"
)
# Filter command
filter_parser = subparsers.add_parser('filter', help='Apply filter to image')
filter_parser.add_argument('input', help='Input image path')
filter_parser.add_argument('output', help='Output image path')
filter_parser.add_argument('type', choices=['blur', 'contour', 'detail', 'edge_enhance',
'edge_enhance_more', 'emboss', 'find_edges',
'sharpen', 'smooth', 'smooth_more', 'gaussian_blur',
'unsharp_mask'], help='Filter type')
filter_parser = subparsers.add_parser("filter", help="Apply filter to image")
filter_parser.add_argument("input", help="Input image path")
filter_parser.add_argument("output", help="Output image path")
filter_parser.add_argument(
"type",
choices=[
"blur",
"contour",
"detail",
"edge_enhance",
"edge_enhance_more",
"emboss",
"find_edges",
"sharpen",
"smooth",
"smooth_more",
"gaussian_blur",
"unsharp_mask",
],
help="Filter type",
)
# Adjust command
adjust_parser = subparsers.add_parser('adjust', help='Adjust image properties')
adjust_parser.add_argument('input', help='Input image path')
adjust_parser.add_argument('output', help='Output image path')
adjust_parser.add_argument('--brightness', type=float, default=1.0, help='Brightness factor (default: 1.0)')
adjust_parser.add_argument('--contrast', type=float, default=1.0, help='Contrast factor (default: 1.0)')
adjust_parser.add_argument('--saturation', type=float, default=1.0, help='Saturation factor (default: 1.0)')
adjust_parser.add_argument('--sharpness', type=float, default=1.0, help='Sharpness factor (default: 1.0)')
adjust_parser = subparsers.add_parser("adjust", help="Adjust image properties")
adjust_parser.add_argument("input", help="Input image path")
adjust_parser.add_argument("output", help="Output image path")
adjust_parser.add_argument(
"--brightness", type=float, default=1.0, help="Brightness factor (default: 1.0)"
)
adjust_parser.add_argument(
"--contrast", type=float, default=1.0, help="Contrast factor (default: 1.0)"
)
adjust_parser.add_argument(
"--saturation", type=float, default=1.0, help="Saturation factor (default: 1.0)"
)
adjust_parser.add_argument(
"--sharpness", type=float, default=1.0, help="Sharpness factor (default: 1.0)"
)
# Crop command
crop_parser = subparsers.add_parser('crop', help='Crop image')
crop_parser.add_argument('input', help='Input image path')
crop_parser.add_argument('output', help='Output image path')
crop_parser.add_argument('x', type=int, help='X coordinate')
crop_parser.add_argument('y', type=int, help='Y coordinate')
crop_parser.add_argument('width', type=int, help='Crop width')
crop_parser.add_argument('height', type=int, help='Crop height')
crop_parser = subparsers.add_parser("crop", help="Crop image")
crop_parser.add_argument("input", help="Input image path")
crop_parser.add_argument("output", help="Output image path")
crop_parser.add_argument("x", type=int, help="X coordinate")
crop_parser.add_argument("y", type=int, help="Y coordinate")
crop_parser.add_argument("width", type=int, help="Crop width")
crop_parser.add_argument("height", type=int, help="Crop height")
# Rotate command
rotate_parser = subparsers.add_parser('rotate', help='Rotate image')
rotate_parser.add_argument('input', help='Input image path')
rotate_parser.add_argument('output', help='Output image path')
rotate_parser.add_argument('angle', type=float, help='Rotation angle in degrees')
rotate_parser.add_argument('--no-expand', action='store_true', help='Don\'t expand canvas')
rotate_parser = subparsers.add_parser("rotate", help="Rotate image")
rotate_parser.add_argument("input", help="Input image path")
rotate_parser.add_argument("output", help="Output image path")
rotate_parser.add_argument("angle", type=float, help="Rotation angle in degrees")
rotate_parser.add_argument(
"--no-expand", action="store_true", help="Don't expand canvas"
)
# Flip command
flip_parser = subparsers.add_parser('flip', help='Flip image')
flip_parser.add_argument('input', help='Input image path')
flip_parser.add_argument('output', help='Output image path')
flip_parser.add_argument('direction', choices=['horizontal', 'vertical'], help='Flip direction')
flip_parser = subparsers.add_parser("flip", help="Flip image")
flip_parser.add_argument("input", help="Input image path")
flip_parser.add_argument("output", help="Output image path")
flip_parser.add_argument(
"direction", choices=["horizontal", "vertical"], help="Flip direction"
)
# Convert command
convert_parser = subparsers.add_parser('convert', help='Convert image format')
convert_parser.add_argument('input', help='Input image path')
convert_parser.add_argument('output', help='Output image path')
convert_parser.add_argument('--format', default='PNG', help='Target format (default: PNG)')
convert_parser = subparsers.add_parser("convert", help="Convert image format")
convert_parser.add_argument("input", help="Input image path")
convert_parser.add_argument("output", help="Output image path")
convert_parser.add_argument(
"--format", default="PNG", help="Target format (default: PNG)"
)
# Thumbnail command
thumbnail_parser = subparsers.add_parser('thumbnail', help='Create thumbnail')
thumbnail_parser.add_argument('input', help='Input image path')
thumbnail_parser.add_argument('output', help='Output image path')
thumbnail_parser.add_argument('--size', type=int, nargs=2, default=[128, 128], help='Thumbnail size (width height)')
thumbnail_parser = subparsers.add_parser("thumbnail", help="Create thumbnail")
thumbnail_parser.add_argument("input", help="Input image path")
thumbnail_parser.add_argument("output", help="Output image path")
thumbnail_parser.add_argument(
"--size",
type=int,
nargs=2,
default=[128, 128],
help="Thumbnail size (width height)",
)
# Watermark command
watermark_parser = subparsers.add_parser('watermark', help='Add watermark')
watermark_parser.add_argument('input', help='Input image path')
watermark_parser.add_argument('output', help='Output image path')
watermark_parser.add_argument('text', help='Watermark text')
watermark_parser.add_argument('--position', choices=['top-left', 'top-right', 'bottom-left',
'bottom-right', 'center'],
default='bottom-right', help='Watermark position')
watermark_parser.add_argument('--opacity', type=int, default=128, help='Watermark opacity (0-255)')
watermark_parser = subparsers.add_parser("watermark", help="Add watermark")
watermark_parser.add_argument("input", help="Input image path")
watermark_parser.add_argument("output", help="Output image path")
watermark_parser.add_argument("text", help="Watermark text")
watermark_parser.add_argument(
"--position",
choices=["top-left", "top-right", "bottom-left", "bottom-right", "center"],
default="bottom-right",
help="Watermark position",
)
watermark_parser.add_argument(
"--opacity", type=int, default=128, help="Watermark opacity (0-255)"
)
# Metadata command
metadata_parser = subparsers.add_parser('metadata', help='Extract image metadata')
metadata_parser.add_argument('input', help='Input image path')
metadata_parser = subparsers.add_parser("metadata", help="Extract image metadata")
metadata_parser.add_argument("input", help="Input image path")
# Collage command
collage_parser = subparsers.add_parser('collage', help='Create collage from multiple images')
collage_parser.add_argument('output', help='Output image path')
collage_parser.add_argument('inputs', nargs='+', help='Input image paths')
collage_parser.add_argument('--cols', type=int, default=2, help='Number of columns')
collage_parser.add_argument('--padding', type=int, default=10, help='Padding between images')
collage_parser = subparsers.add_parser(
"collage", help="Create collage from multiple images"
)
collage_parser.add_argument("output", help="Output image path")
collage_parser.add_argument("inputs", nargs="+", help="Input image paths")
collage_parser.add_argument("--cols", type=int, default=2, help="Number of columns")
collage_parser.add_argument(
"--padding", type=int, default=10, help="Padding between images"
)
# Effect command
effect_parser = subparsers.add_parser('effect', help='Apply artistic effects')
effect_parser.add_argument('input', help='Input image path')
effect_parser.add_argument('output', help='Output image path')
effect_parser.add_argument('type', choices=['sepia', 'grayscale', 'invert', 'posterize', 'solarize'],
help='Effect type')
effect_parser = subparsers.add_parser("effect", help="Apply artistic effects")
effect_parser.add_argument("input", help="Input image path")
effect_parser.add_argument("output", help="Output image path")
effect_parser.add_argument(
"type",
choices=["sepia", "grayscale", "invert", "posterize", "solarize"],
help="Effect type",
)
# Batch command
batch_parser = subparsers.add_parser('batch', help='Batch process images')
batch_parser.add_argument('input_dir', help='Input directory')
batch_parser.add_argument('output_dir', help='Output directory')
batch_parser.add_argument('operation', choices=['resize', 'filter', 'adjust', 'effect', 'thumbnail'],
help='Batch operation')
batch_parser.add_argument('--width', type=int, help='Width for resize operation')
batch_parser.add_argument('--height', type=int, help='Height for resize operation')
batch_parser.add_argument('--filter-type', help='Filter type for filter operation')
batch_parser.add_argument('--effect-type', help='Effect type for effect operation')
batch_parser.add_argument('--size', type=int, nargs=2, default=[128, 128], help='Size for thumbnail operation')
batch_parser = subparsers.add_parser("batch", help="Batch process images")
batch_parser.add_argument("input_dir", help="Input directory")
batch_parser.add_argument("output_dir", help="Output directory")
batch_parser.add_argument(
"operation",
choices=["resize", "filter", "adjust", "effect", "thumbnail"],
help="Batch operation",
)
batch_parser.add_argument("--width", type=int, help="Width for resize operation")
batch_parser.add_argument("--height", type=int, help="Height for resize operation")
batch_parser.add_argument("--filter-type", help="Filter type for filter operation")
batch_parser.add_argument("--effect-type", help="Effect type for effect operation")
batch_parser.add_argument(
"--size",
type=int,
nargs=2,
default=[128, 128],
help="Size for thumbnail operation",
)
# GUI command
gui_parser = subparsers.add_parser('gui', help='Launch simple GUI')
gui_parser = subparsers.add_parser("gui", help="Launch simple GUI")
args = parser.parse_args()
@ -757,63 +907,81 @@ def main():
cli = PillowCLI()
try:
if args.command == 'resize':
cli.resize_image(args.input, args.output, args.width, args.height,
maintain_aspect=not args.no_aspect)
if args.command == "resize":
cli.resize_image(
args.input,
args.output,
args.width,
args.height,
maintain_aspect=not args.no_aspect,
)
elif args.command == 'filter':
elif args.command == "filter":
cli.apply_filters(args.input, args.output, args.type)
elif args.command == 'adjust':
cli.adjust_image(args.input, args.output, args.brightness, args.contrast,
args.saturation, args.sharpness)
elif args.command == "adjust":
cli.adjust_image(
args.input,
args.output,
args.brightness,
args.contrast,
args.saturation,
args.sharpness,
)
elif args.command == 'crop':
cli.crop_image(args.input, args.output, args.x, args.y, args.width, args.height)
elif args.command == "crop":
cli.crop_image(
args.input, args.output, args.x, args.y, args.width, args.height
)
elif args.command == 'rotate':
cli.rotate_image(args.input, args.output, args.angle, expand=not args.no_expand)
elif args.command == "rotate":
cli.rotate_image(
args.input, args.output, args.angle, expand=not args.no_expand
)
elif args.command == 'flip':
elif args.command == "flip":
cli.flip_image(args.input, args.output, args.direction)
elif args.command == 'convert':
elif args.command == "convert":
cli.convert_format(args.input, args.output, args.format)
elif args.command == 'thumbnail':
elif args.command == "thumbnail":
cli.create_thumbnail(args.input, args.output, tuple(args.size))
elif args.command == 'watermark':
cli.add_watermark(args.input, args.output, args.text, args.position, args.opacity)
elif args.command == "watermark":
cli.add_watermark(
args.input, args.output, args.text, args.position, args.opacity
)
elif args.command == 'metadata':
elif args.command == "metadata":
cli.extract_metadata(args.input)
elif args.command == 'collage':
elif args.command == "collage":
cli.create_collage(args.inputs, args.output, args.cols, args.padding)
elif args.command == 'effect':
elif args.command == "effect":
cli.apply_artistic_effects(args.input, args.output, args.type)
elif args.command == 'batch':
elif args.command == "batch":
kwargs = {}
if args.operation == 'resize':
kwargs = {'width': args.width, 'height': args.height}
elif args.operation == 'filter':
kwargs = {'filter_type': args.filter_type}
elif args.operation == 'effect':
kwargs = {'effect': args.effect_type}
elif args.operation == 'thumbnail':
kwargs = {'size': tuple(args.size)}
if args.operation == "resize":
kwargs = {"width": args.width, "height": args.height}
elif args.operation == "filter":
kwargs = {"filter_type": args.filter_type}
elif args.operation == "effect":
kwargs = {"effect": args.effect_type}
elif args.operation == "thumbnail":
kwargs = {"size": tuple(args.size)}
cli.batch_process(args.input_dir, args.output_dir, args.operation, **kwargs)
elif args.command == 'gui':
elif args.command == "gui":
cli.simple_gui()
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -2,10 +2,14 @@
Test setup and fixtures for the Pillow CLI project
"""
import pytest
import tempfile
import shutil
from __future__ import annotations
import os
import shutil
import tempfile
import pytest
from PIL import Image, ImageDraw
@ -20,7 +24,10 @@ def test_data_dir():
@pytest.fixture
def create_test_image():
"""Helper to make test images on demand"""
def _create_image(size=(100, 100), color='red', format='JPEG', mode='RGB', save_path=None):
def _create_image(
size=(100, 100), color="red", format="JPEG", mode="RGB", save_path=None
):
image = Image.new(mode, size, color=color)
# Add some visual elements to make it more realistic
@ -31,16 +38,19 @@ def create_test_image():
x1, y1 = size[0] // 4, size[1] // 4
x2, y2 = x1 + rect_size, y1 + rect_size
if mode == 'RGB':
draw.rectangle([x1, y1, x2, y2], fill='blue')
elif mode == 'RGBA':
if mode == "RGB":
draw.rectangle([x1, y1, x2, y2], fill="blue")
elif mode == "RGBA":
draw.rectangle([x1, y1, x2, y2], fill=(0, 0, 255, 200))
# Circle around the edge if there's room
if size[0] >= 20 and size[1] >= 20:
margin = 5
draw.ellipse([margin, margin, size[0] - margin, size[1] - margin],
outline='green', width=2)
draw.ellipse(
[margin, margin, size[0] - margin, size[1] - margin],
outline="green",
width=2,
)
if save_path:
image.save(save_path, format)
@ -54,7 +64,7 @@ def create_test_image():
def sample_images_batch(test_data_dir, create_test_image):
"""Makes a batch of sample images for testing"""
images = []
colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange']
colors = ["red", "green", "blue", "yellow", "purple", "orange"]
sizes = [(50, 50), (100, 100), (150, 100), (100, 150)]
for i, (color, size) in enumerate(zip(colors, sizes * 2)): # cycle through sizes
@ -74,34 +84,30 @@ def sample_formats(test_data_dir, create_test_image):
# JPEG
jpeg_path = os.path.join(test_data_dir, "sample.jpg")
formats_data['jpeg'] = create_test_image(save_path=jpeg_path, format='JPEG')
formats_data["jpeg"] = create_test_image(save_path=jpeg_path, format="JPEG")
# PNG with transparency
png_path = os.path.join(test_data_dir, "sample.png")
formats_data['png'] = create_test_image(save_path=png_path, format='PNG', mode='RGBA')
formats_data["png"] = create_test_image(
save_path=png_path, format="PNG", mode="RGBA"
)
# BMP
bmp_path = os.path.join(test_data_dir, "sample.bmp")
formats_data['bmp'] = create_test_image(save_path=bmp_path, format='BMP')
formats_data["bmp"] = create_test_image(save_path=bmp_path, format="BMP")
# GIF
gif_path = os.path.join(test_data_dir, "sample.gif")
formats_data['gif'] = create_test_image(save_path=gif_path, format='GIF')
formats_data["gif"] = create_test_image(save_path=gif_path, format="GIF")
return formats_data
def pytest_configure(config):
"""Add our custom test markers"""
config.addinivalue_line(
"markers", "unit: mark test as a unit test"
)
config.addinivalue_line(
"markers", "integration: mark test as an integration test"
)
config.addinivalue_line(
"markers", "slow: mark test as slow running"
)
config.addinivalue_line("markers", "unit: mark test as a unit test")
config.addinivalue_line("markers", "integration: mark test as an integration test")
config.addinivalue_line("markers", "slow: mark test as slow running")
config.addinivalue_line(
"markers", "gui: mark test as GUI-related (may require display)"
)

View File

@ -3,20 +3,22 @@
Test suite for Pillow CLI tool
Comprehensive tests covering all functionality
"""
from __future__ import annotations
import pytest
import os
import tempfile
import shutil
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from unittest.mock import patch, MagicMock, mock_open
import os
import shutil
# Import the CLI class
import sys
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image, ImageDraw
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from pillow_cli import PillowCLI
@ -40,6 +42,7 @@ class TestPillowCLI:
except PermissionError:
# Try again after a brief delay
import time
time.sleep(0.1)
try:
shutil.rmtree(temp_dir)
@ -51,33 +54,33 @@ class TestPillowCLI:
"""Create a sample image for testing"""
image_path = os.path.join(temp_dir, "sample.jpg")
# Create a simple 100x100 RGB image
image = Image.new('RGB', (100, 100), color='red')
image = Image.new("RGB", (100, 100), color="red")
# Add some content to make it more realistic
draw = ImageDraw.Draw(image)
draw.rectangle([25, 25, 75, 75], fill='blue')
draw.ellipse([10, 10, 90, 90], outline='green', width=3)
image.save(image_path, 'JPEG')
draw.rectangle([25, 25, 75, 75], fill="blue")
draw.ellipse([10, 10, 90, 90], outline="green", width=3)
image.save(image_path, "JPEG")
return image_path
@pytest.fixture
def sample_png_image(self, temp_dir):
"""Create a sample PNG image with transparency"""
image_path = os.path.join(temp_dir, "sample.png")
image = Image.new('RGBA', (100, 100), color=(255, 0, 0, 128))
image = Image.new("RGBA", (100, 100), color=(255, 0, 0, 128))
draw = ImageDraw.Draw(image)
draw.rectangle([25, 25, 75, 75], fill=(0, 0, 255, 200))
image.save(image_path, 'PNG')
image.save(image_path, "PNG")
return image_path
@pytest.fixture
def multiple_sample_images(self, temp_dir):
"""Create multiple sample images for collage testing"""
images = []
colors = ['red', 'green', 'blue', 'yellow']
colors = ["red", "green", "blue", "yellow"]
for i, color in enumerate(colors):
image_path = os.path.join(temp_dir, f"sample_{i}.jpg")
image = Image.new('RGB', (50, 50), color=color)
image.save(image_path, 'JPEG')
image = Image.new("RGB", (50, 50), color=color)
image.save(image_path, "JPEG")
images.append(image_path)
return images
@ -85,8 +88,8 @@ class TestPillowCLI:
def test_cli_initialization(self, cli):
"""Test CLI initialization"""
assert isinstance(cli, PillowCLI)
assert hasattr(cli, 'supported_formats')
expected_formats = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp'}
assert hasattr(cli, "supported_formats")
expected_formats = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp"}
assert cli.supported_formats == expected_formats
# Test image validation
@ -102,7 +105,7 @@ class TestPillowCLI:
def test_validate_image_path_unsupported_format(self, cli, temp_dir):
"""Test validation of unsupported image format"""
unsupported_file = os.path.join(temp_dir, "test.txt")
with open(unsupported_file, 'w') as f:
with open(unsupported_file, "w") as f:
f.write("test")
with pytest.raises(ValueError, match="Unsupported image format"):
@ -114,15 +117,15 @@ class TestPillowCLI:
image = cli.load_image(sample_image)
assert isinstance(image, Image.Image)
assert image.size == (100, 100)
assert image.mode in ['RGB', 'RGBA']
assert image.mode in ["RGB", "RGBA"]
# Test image saving
def test_save_image_jpg(self, cli, temp_dir):
"""Test saving image as JPEG"""
image = Image.new('RGB', (50, 50), color='red')
image = Image.new("RGB", (50, 50), color="red")
output_path = os.path.join(temp_dir, "output.jpg")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.save_image(image, output_path)
assert os.path.exists(output_path)
@ -132,18 +135,18 @@ class TestPillowCLI:
def test_save_image_rgba_to_jpg(self, cli, temp_dir):
"""Test saving RGBA image as JPEG (should convert to RGB)"""
image = Image.new('RGBA', (50, 50), color=(255, 0, 0, 128))
image = Image.new("RGBA", (50, 50), color=(255, 0, 0, 128))
output_path = os.path.join(temp_dir, "output.jpg")
cli.save_image(image, output_path)
assert os.path.exists(output_path)
saved_image = Image.open(output_path)
assert saved_image.mode == 'RGB'
assert saved_image.mode == "RGB"
def test_save_image_create_directory(self, cli, temp_dir):
"""Test saving image creates output directory if it doesn't exist"""
image = Image.new('RGB', (50, 50), color='red')
image = Image.new("RGB", (50, 50), color="red")
output_path = os.path.join(temp_dir, "subdir", "output.jpg")
cli.save_image(image, output_path)
@ -155,7 +158,7 @@ class TestPillowCLI:
"""Test resizing with both width and height specified"""
output_path = os.path.join(temp_dir, "resized.jpg")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.resize_image(sample_image, output_path, width=200, height=150)
assert os.path.exists(output_path)
@ -190,11 +193,15 @@ class TestPillowCLI:
with pytest.raises(ValueError, match="Must specify at least width or height"):
cli.resize_image(sample_image, output_path)
def test_resize_image_no_aspect_ratio_maintenance(self, cli, sample_image, temp_dir):
def test_resize_image_no_aspect_ratio_maintenance(
self, cli, sample_image, temp_dir
):
"""Test resizing without maintaining aspect ratio"""
output_path = os.path.join(temp_dir, "resized.jpg")
cli.resize_image(sample_image, output_path, width=200, height=100, maintain_aspect=False)
cli.resize_image(
sample_image, output_path, width=200, height=100, maintain_aspect=False
)
resized_image = Image.open(output_path)
assert resized_image.size == (200, 100)
@ -204,16 +211,24 @@ class TestPillowCLI:
"""Test applying blur filter"""
output_path = os.path.join(temp_dir, "blurred.jpg")
with patch('builtins.print') as mock_print:
cli.apply_filters(sample_image, output_path, 'blur')
with patch("builtins.print") as mock_print:
cli.apply_filters(sample_image, output_path, "blur")
assert os.path.exists(output_path)
mock_print.assert_any_call("Applied blur filter")
def test_apply_filters_all_types(self, cli, sample_image, temp_dir):
"""Test all available filter types"""
filter_types = ['blur', 'contour', 'detail', 'edge_enhance', 'sharpen',
'smooth', 'gaussian_blur', 'unsharp_mask']
filter_types = [
"blur",
"contour",
"detail",
"edge_enhance",
"sharpen",
"smooth",
"gaussian_blur",
"unsharp_mask",
]
for filter_type in filter_types:
output_path = os.path.join(temp_dir, f"{filter_type}.jpg")
@ -225,20 +240,27 @@ class TestPillowCLI:
output_path = os.path.join(temp_dir, "filtered.jpg")
with pytest.raises(ValueError, match="Unknown filter"):
cli.apply_filters(sample_image, output_path, 'invalid_filter')
cli.apply_filters(sample_image, output_path, "invalid_filter")
# Test image adjustment
def test_adjust_image_all_parameters(self, cli, sample_image, temp_dir):
"""Test adjusting all image parameters"""
output_path = os.path.join(temp_dir, "adjusted.jpg")
with patch('builtins.print') as mock_print:
cli.adjust_image(sample_image, output_path,
brightness=1.2, contrast=1.1,
saturation=0.9, sharpness=1.3)
with patch("builtins.print") as mock_print:
cli.adjust_image(
sample_image,
output_path,
brightness=1.2,
contrast=1.1,
saturation=0.9,
sharpness=1.3,
)
assert os.path.exists(output_path)
mock_print.assert_any_call("Adjusted image - Brightness: 1.2, Contrast: 1.1, Saturation: 0.9, Sharpness: 1.3")
mock_print.assert_any_call(
"Adjusted image - Brightness: 1.2, Contrast: 1.1, Saturation: 0.9, Sharpness: 1.3"
)
def test_adjust_image_single_parameter(self, cli, sample_image, temp_dir):
"""Test adjusting single parameter"""
@ -253,7 +275,7 @@ class TestPillowCLI:
"""Test valid image cropping"""
output_path = os.path.join(temp_dir, "cropped.jpg")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.crop_image(sample_image, output_path, 10, 10, 50, 50)
assert os.path.exists(output_path)
@ -273,7 +295,7 @@ class TestPillowCLI:
"""Test image rotation"""
output_path = os.path.join(temp_dir, "rotated.jpg")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.rotate_image(sample_image, output_path, 45)
assert os.path.exists(output_path)
@ -294,8 +316,8 @@ class TestPillowCLI:
"""Test horizontal image flip"""
output_path = os.path.join(temp_dir, "flipped.jpg")
with patch('builtins.print') as mock_print:
cli.flip_image(sample_image, output_path, 'horizontal')
with patch("builtins.print") as mock_print:
cli.flip_image(sample_image, output_path, "horizontal")
assert os.path.exists(output_path)
mock_print.assert_any_call("Flipped image horizontally")
@ -304,8 +326,8 @@ class TestPillowCLI:
"""Test vertical image flip"""
output_path = os.path.join(temp_dir, "flipped.jpg")
with patch('builtins.print') as mock_print:
cli.flip_image(sample_image, output_path, 'vertical')
with patch("builtins.print") as mock_print:
cli.flip_image(sample_image, output_path, "vertical")
assert os.path.exists(output_path)
mock_print.assert_any_call("Flipped image vertically")
@ -314,39 +336,41 @@ class TestPillowCLI:
"""Test flipping with invalid direction"""
output_path = os.path.join(temp_dir, "flipped.jpg")
with pytest.raises(ValueError, match="Direction must be 'horizontal' or 'vertical'"):
cli.flip_image(sample_image, output_path, 'diagonal')
with pytest.raises(
ValueError, match="Direction must be 'horizontal' or 'vertical'"
):
cli.flip_image(sample_image, output_path, "diagonal")
# Test format conversion
def test_convert_format_png(self, cli, sample_image, temp_dir):
"""Test converting to PNG format"""
output_path = os.path.join(temp_dir, "converted.png")
with patch('builtins.print') as mock_print:
cli.convert_format(sample_image, output_path, 'PNG')
with patch("builtins.print") as mock_print:
cli.convert_format(sample_image, output_path, "PNG")
assert os.path.exists(output_path)
converted_image = Image.open(output_path)
assert converted_image.format == 'PNG'
assert converted_image.format == "PNG"
mock_print.assert_any_call("Converted to PNG format")
def test_convert_rgba_to_jpeg(self, cli, sample_png_image, temp_dir):
"""Test converting RGBA image to JPEG"""
output_path = os.path.join(temp_dir, "converted.jpg")
cli.convert_format(sample_png_image, output_path, 'JPEG')
cli.convert_format(sample_png_image, output_path, "JPEG")
assert os.path.exists(output_path)
converted_image = Image.open(output_path)
assert converted_image.format == 'JPEG'
assert converted_image.mode == 'RGB'
assert converted_image.format == "JPEG"
assert converted_image.mode == "RGB"
# Test thumbnail creation
def test_create_thumbnail(self, cli, sample_image, temp_dir):
"""Test thumbnail creation"""
output_path = os.path.join(temp_dir, "thumb.jpg")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.create_thumbnail(sample_image, output_path, (64, 64))
assert os.path.exists(output_path)
@ -360,7 +384,7 @@ class TestPillowCLI:
"""Test adding watermark"""
output_path = os.path.join(temp_dir, "watermarked.jpg")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.add_watermark(sample_image, output_path, "Test Watermark")
assert os.path.exists(output_path)
@ -368,7 +392,7 @@ class TestPillowCLI:
def test_add_watermark_all_positions(self, cli, sample_image, temp_dir):
"""Test watermark at all positions"""
positions = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'center']
positions = ["top-left", "top-right", "bottom-left", "bottom-right", "center"]
for position in positions:
output_path = os.path.join(temp_dir, f"watermark_{position}.jpg")
@ -380,22 +404,22 @@ class TestPillowCLI:
output_path = os.path.join(temp_dir, "watermarked.jpg")
with pytest.raises(ValueError, match="Invalid position"):
cli.add_watermark(sample_image, output_path, "Test", position='invalid')
cli.add_watermark(sample_image, output_path, "Test", position="invalid")
# Test metadata extraction
def test_extract_metadata(self, cli, sample_image):
"""Test metadata extraction"""
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
metadata = cli.extract_metadata(sample_image)
assert isinstance(metadata, dict)
assert 'filename' in metadata
assert 'format' in metadata
assert 'size' in metadata
assert 'width' in metadata
assert 'height' in metadata
assert metadata['width'] == 100
assert metadata['height'] == 100
assert "filename" in metadata
assert "format" in metadata
assert "size" in metadata
assert "width" in metadata
assert "height" in metadata
assert metadata["width"] == 100
assert metadata["height"] == 100
# Check that JSON was printed
mock_print.assert_called()
@ -405,7 +429,7 @@ class TestPillowCLI:
"""Test collage creation"""
output_path = os.path.join(temp_dir, "collage.jpg")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.create_collage(multiple_sample_images, output_path, cols=2, padding=5)
assert os.path.exists(output_path)
@ -426,15 +450,15 @@ class TestPillowCLI:
"""Test sepia effect"""
output_path = os.path.join(temp_dir, "sepia.jpg")
with patch('builtins.print') as mock_print:
cli.apply_artistic_effects(sample_image, output_path, 'sepia')
with patch("builtins.print") as mock_print:
cli.apply_artistic_effects(sample_image, output_path, "sepia")
assert os.path.exists(output_path)
mock_print.assert_any_call("Applied sepia effect")
def test_apply_artistic_effects_all_types(self, cli, sample_image, temp_dir):
"""Test all artistic effects"""
effects = ['sepia', 'grayscale', 'invert', 'posterize', 'solarize']
effects = ["sepia", "grayscale", "invert", "posterize", "solarize"]
for effect in effects:
output_path = os.path.join(temp_dir, f"{effect}.jpg")
@ -446,20 +470,20 @@ class TestPillowCLI:
output_path = os.path.join(temp_dir, "effect.jpg")
with pytest.raises(ValueError, match="Unknown effect"):
cli.apply_artistic_effects(sample_image, output_path, 'invalid_effect')
cli.apply_artistic_effects(sample_image, output_path, "invalid_effect")
# Test histogram analysis
def test_analyze_histogram(self, cli, sample_image, temp_dir):
"""Test histogram analysis"""
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
histogram_data = cli.analyze_histogram(sample_image)
assert isinstance(histogram_data, dict)
assert 'red_channel' in histogram_data
assert 'green_channel' in histogram_data
assert 'blue_channel' in histogram_data
assert 'total_pixels' in histogram_data
assert histogram_data['total_pixels'] == 10000 # 100x100
assert "red_channel" in histogram_data
assert "green_channel" in histogram_data
assert "blue_channel" in histogram_data
assert "total_pixels" in histogram_data
assert histogram_data["total_pixels"] == 10000 # 100x100
mock_print.assert_called()
@ -467,21 +491,21 @@ class TestPillowCLI:
"""Test histogram analysis with output file"""
output_path = os.path.join(temp_dir, "histogram.json")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.analyze_histogram(sample_image, output_path)
assert os.path.exists(output_path)
with open(output_path, 'r') as f:
with open(output_path) as f:
histogram_data = json.load(f)
assert isinstance(histogram_data, dict)
assert 'total_pixels' in histogram_data
assert "total_pixels" in histogram_data
mock_print.assert_any_call(f"Histogram data saved to: {output_path}")
# Test color palette extraction
def test_extract_color_palette(self, cli, sample_image):
"""Test color palette extraction"""
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
colors = cli.extract_color_palette(sample_image, num_colors=3)
assert isinstance(colors, list)
@ -496,31 +520,40 @@ class TestPillowCLI:
def test_extract_color_palette_without_sklearn(self, cli, sample_image):
"""Test color palette extraction without sklearn (fallback method)"""
with patch('sklearn.cluster.KMeans', side_effect=ImportError()):
with patch('builtins.print') as mock_print:
with patch("sklearn.cluster.KMeans", side_effect=ImportError()):
with patch("builtins.print") as mock_print:
colors = cli.extract_color_palette(sample_image, num_colors=3)
assert isinstance(colors, list)
mock_print.assert_any_call("scikit-learn not installed. Using simple color extraction...")
mock_print.assert_any_call(
"scikit-learn not installed. Using simple color extraction..."
)
# Test border creation
def test_create_border(self, cli, sample_image, temp_dir):
"""Test border creation"""
output_path = os.path.join(temp_dir, "bordered.jpg")
with patch('builtins.print') as mock_print:
cli.create_border(sample_image, output_path, border_width=5, border_color='red')
with patch("builtins.print") as mock_print:
cli.create_border(
sample_image, output_path, border_width=5, border_color="red"
)
assert os.path.exists(output_path)
bordered_image = Image.open(output_path)
assert bordered_image.size == (110, 110) # Original 100x100 + 5px border on each side
assert bordered_image.size == (
110,
110,
) # Original 100x100 + 5px border on each side
mock_print.assert_any_call("Added 5px border with color (255, 0, 0)")
def test_create_border_hex_color(self, cli, sample_image, temp_dir):
"""Test border with hex color"""
output_path = os.path.join(temp_dir, "bordered.jpg")
cli.create_border(sample_image, output_path, border_width=3, border_color='#FF0000')
cli.create_border(
sample_image, output_path, border_width=3, border_color="#FF0000"
)
assert os.path.exists(output_path)
@ -532,24 +565,28 @@ class TestPillowCLI:
overlay_path = os.path.join(temp_dir, "overlay.png")
output_path = os.path.join(temp_dir, "composite.png")
bg_image = Image.new('RGBA', (100, 100), color=(255, 0, 0, 255))
overlay_image = Image.new('RGBA', (50, 50), color=(0, 0, 255, 128))
bg_image = Image.new("RGBA", (100, 100), color=(255, 0, 0, 255))
overlay_image = Image.new("RGBA", (50, 50), color=(0, 0, 255, 128))
bg_image.save(bg_path, 'PNG')
overlay_image.save(overlay_path, 'PNG')
bg_image.save(bg_path, "PNG")
overlay_image.save(overlay_path, "PNG")
with patch('builtins.print') as mock_print:
cli.create_composite(bg_path, overlay_path, output_path, position=(25, 25), opacity=0.8)
with patch("builtins.print") as mock_print:
cli.create_composite(
bg_path, overlay_path, output_path, position=(25, 25), opacity=0.8
)
assert os.path.exists(output_path)
mock_print.assert_any_call("Created composite image at position (25, 25) with opacity 0.8")
mock_print.assert_any_call(
"Created composite image at position (25, 25) with opacity 0.8"
)
# Test vignette effect
def test_apply_vignette(self, cli, sample_image, temp_dir):
"""Test vignette effect"""
output_path = os.path.join(temp_dir, "vignette.jpg")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
cli.apply_vignette(sample_image, output_path, strength=0.3)
assert os.path.exists(output_path)
@ -560,8 +597,10 @@ class TestPillowCLI:
"""Test contact sheet creation"""
output_path = os.path.join(temp_dir, "contact_sheet.jpg")
with patch('builtins.print') as mock_print:
cli.create_contact_sheet(multiple_sample_images, output_path, sheet_width=800, margin=5)
with patch("builtins.print") as mock_print:
cli.create_contact_sheet(
multiple_sample_images, output_path, sheet_width=800, margin=5
)
assert os.path.exists(output_path)
contact_image = Image.open(output_path)
@ -581,7 +620,7 @@ class TestPillowCLI:
"""Test RGB channel splitting"""
output_dir = os.path.join(temp_dir, "channels")
with patch('builtins.print') as mock_print:
with patch("builtins.print") as mock_print:
channel_paths = cli.split_channels(sample_image, output_dir)
assert len(channel_paths) == 3
@ -590,7 +629,11 @@ class TestPillowCLI:
# Check that files have correct names
base_name = Path(sample_image).stem
expected_files = [f"{base_name}_red.png", f"{base_name}_green.png", f"{base_name}_blue.png"]
expected_files = [
f"{base_name}_red.png",
f"{base_name}_green.png",
f"{base_name}_blue.png",
]
for expected_file in expected_files:
assert any(expected_file in path for path in channel_paths)
@ -607,11 +650,11 @@ class TestPillowCLI:
# Create sample images
for i in range(3):
image_path = os.path.join(input_dir, f"image_{i}.jpg")
image = Image.new('RGB', (100, 100), color='red')
image.save(image_path, 'JPEG')
image = Image.new("RGB", (100, 100), color="red")
image.save(image_path, "JPEG")
with patch('builtins.print') as mock_print:
cli.batch_process(input_dir, output_dir, 'resize', width=50, height=50)
with patch("builtins.print") as mock_print:
cli.batch_process(input_dir, output_dir, "resize", width=50, height=50)
# Check output directory exists and contains processed images
assert os.path.exists(output_dir)
@ -620,7 +663,9 @@ class TestPillowCLI:
# Fix: The actual implementation finds both .jpg and .JPG files, so it finds 6 files total
# Let's check for the correct number that the implementation actually finds
mock_print.assert_any_call("Batch processing complete! Output saved to: " + output_dir)
mock_print.assert_any_call(
"Batch processing complete! Output saved to: " + output_dir
)
def test_batch_process_no_images(self, cli, temp_dir):
"""Test batch processing with no images in directory"""
@ -628,8 +673,8 @@ class TestPillowCLI:
output_dir = os.path.join(temp_dir, "output")
os.makedirs(input_dir)
with patch('builtins.print') as mock_print:
cli.batch_process(input_dir, output_dir, 'resize')
with patch("builtins.print") as mock_print:
cli.batch_process(input_dir, output_dir, "resize")
mock_print.assert_any_call("No image files found in the input directory")
@ -639,7 +684,7 @@ class TestPillowCLI:
output_dir = os.path.join(temp_dir, "output")
with pytest.raises(FileNotFoundError, match="Input directory not found"):
cli.batch_process(input_dir, output_dir, 'resize')
cli.batch_process(input_dir, output_dir, "resize")
def test_batch_process_unknown_operation(self, cli, temp_dir):
"""Test batch processing with unknown operation"""
@ -649,25 +694,27 @@ class TestPillowCLI:
# Create a sample image
image_path = os.path.join(input_dir, "image.jpg")
image = Image.new('RGB', (100, 100), color='red')
image.save(image_path, 'JPEG')
image = Image.new("RGB", (100, 100), color="red")
image.save(image_path, "JPEG")
with patch('builtins.print') as mock_print:
cli.batch_process(input_dir, output_dir, 'unknown_operation')
with patch("builtins.print") as mock_print:
cli.batch_process(input_dir, output_dir, "unknown_operation")
mock_print.assert_any_call("Unknown batch operation: unknown_operation")
# Test GUI functionality
def test_simple_gui_not_available(self, cli):
"""Test GUI when tkinter is not available"""
with patch('pillow_cli.GUI_AVAILABLE', False):
with patch('builtins.print') as mock_print:
with patch("pillow_cli.GUI_AVAILABLE", False):
with patch("builtins.print") as mock_print:
cli.simple_gui()
mock_print.assert_called_with("GUI not available. Install tkinter to use this feature.")
mock_print.assert_called_with(
"GUI not available. Install tkinter to use this feature."
)
@patch('pillow_cli.GUI_AVAILABLE', True)
@patch('pillow_cli.tk')
@patch("pillow_cli.GUI_AVAILABLE", True)
@patch("pillow_cli.tk")
def test_simple_gui_available(self, mock_tk_module, cli):
"""Test GUI when tkinter is available"""
# Mock the entire tk module and its components
@ -677,7 +724,7 @@ class TestPillowCLI:
# Mock ttk as well
mock_ttk = MagicMock()
with patch('pillow_cli.ttk', mock_ttk):
with patch("pillow_cli.ttk", mock_ttk):
cli.simple_gui()
mock_tk_module.Tk.assert_called_once()
@ -703,10 +750,10 @@ class TestIntegration:
def sample_image(self, temp_dir):
"""Create a sample image for integration testing"""
image_path = os.path.join(temp_dir, "sample.jpg")
image = Image.new('RGB', (200, 200), color='blue')
image = Image.new("RGB", (200, 200), color="blue")
draw = ImageDraw.Draw(image)
draw.rectangle([50, 50, 150, 150], fill='yellow')
image.save(image_path, 'JPEG')
draw.rectangle([50, 50, 150, 150], fill="yellow")
image.save(image_path, "JPEG")
return image_path
def test_image_processing_chain(self, temp_dir, sample_image):
@ -719,11 +766,11 @@ class TestIntegration:
# Step 2: Apply filter
filtered_path = os.path.join(temp_dir, "step2_filtered.jpg")
cli.apply_filters(resized_path, filtered_path, 'sharpen')
cli.apply_filters(resized_path, filtered_path, "sharpen")
# Step 3: Add watermark
final_path = os.path.join(temp_dir, "step3_final.jpg")
cli.add_watermark(filtered_path, final_path, "Processed", position='center')
cli.add_watermark(filtered_path, final_path, "Processed", position="center")
assert os.path.exists(final_path)
final_image = Image.open(final_path)
@ -735,11 +782,11 @@ class TestIntegration:
# JPG to PNG
png_path = os.path.join(temp_dir, "converted.png")
cli.convert_format(sample_image, png_path, 'PNG')
cli.convert_format(sample_image, png_path, "PNG")
# PNG to BMP
bmp_path = os.path.join(temp_dir, "converted.bmp")
cli.convert_format(png_path, bmp_path, 'BMP')
cli.convert_format(png_path, bmp_path, "BMP")
assert os.path.exists(png_path)
assert os.path.exists(bmp_path)
@ -747,8 +794,8 @@ class TestIntegration:
png_image = Image.open(png_path)
bmp_image = Image.open(bmp_path)
assert png_image.format == 'PNG'
assert bmp_image.format == 'BMP'
assert png_image.format == "PNG"
assert bmp_image.format == "BMP"
assert png_image.size == bmp_image.size
@ -771,7 +818,7 @@ class TestErrorHandling:
# Create a file with wrong extension
corrupted_path = os.path.join(temp_dir, "corrupted.jpg")
with open(corrupted_path, 'w') as f:
with open(corrupted_path, "w") as f:
f.write("This is not an image file")
with pytest.raises(Exception): # PIL will raise an exception
@ -783,8 +830,8 @@ class TestErrorHandling:
# Create a small image
image_path = os.path.join(temp_dir, "small.jpg")
image = Image.new('RGB', (10, 10), color='red')
image.save(image_path, 'JPEG')
image = Image.new("RGB", (10, 10), color="red")
image.save(image_path, "JPEG")
output_path = os.path.join(temp_dir, "large.jpg")
@ -800,8 +847,8 @@ class TestErrorHandling:
cli = PillowCLI()
image_path = os.path.join(temp_dir, "test.jpg")
image = Image.new('RGB', (100, 100), color='red')
image.save(image_path, 'JPEG')
image = Image.new("RGB", (100, 100), color="red")
image.save(image_path, "JPEG")
output_path = os.path.join(temp_dir, "cropped.jpg")