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

View File

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

View File

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