
Image Enhancement Suite
- 266 installs
- 84 repo stars
- Updated April 8, 2026
- dkyazzentwatwa/chatgpt-skills
image-enhancement-suite is an agent skill with Python image scripts for developers who need to upscale, clean up, and format screenshots and marketing assets without a dedicated design toolchain.
About
image-enhancement-suite is the primary image toolkit in dkyazzentwatwa/chatgpt-skills, bundling general and specialized Python helpers for single-image and batch workflows. Developers start with scripts/image_enhancer.py for resize, crop, watermark, compress, and format conversion, then reach for focused modules including background_remover.py, image_comparison.py, image_metadata.py, image_filter.py, color_palette_extractor.py, icon_generator.py, collage_maker.py, and sprite_sheet_generator.py. The skill targets landing pages, app UI screenshots, and product marketing assets where quick cleanup beats manual Photoshop work. Guardrails require preserving originals when quality tradeoffs are uncertain, flagging heuristic background removal, and choosing vector-oriented tooling when the request is illustration rather than raster editing. Batch mode applies only when the same transforms should run consistently across a folder of files.
- Upscale and sharpen workflows
- Noise and artifact cleanup
- Crop and composition guidance
- Brand-consistent visual polish
- Batch-ready asset preparation
Image Enhancement Suite by the numbers
- 266 all-time installs (skills.sh)
- Ranked #540 of 1,337 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dkyazzentwatwa/chatgpt-skills --skill image-enhancement-suiteAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 266 |
|---|---|
| repo stars | ★ 84 |
| Last updated | April 8, 2026 |
| Repository | dkyazzentwatwa/chatgpt-skills ↗ |
How do you batch resize and clean app screenshots?
Enhance, upscale, clean up, and prepare images for landing pages, app UI, marketing assets, and product screenshots without a separate design toolchain.
Who is it for?
Developers preparing UI screenshots, marketing hero images, or icon sets who want scriptable PIL-style transforms inside an agent session.
Skip if: Vector illustration authoring, video editing, or brand-system design work that needs Figma or professional retouching judgment.
When should I use this skill?
Raster images need resize, compression, background removal, palette extraction, or sprite-sheet assembly for a web or mobile release.
What you get
Resized, compressed, or background-removed image files, extracted color palettes, icons, collages, or sprite sheets saved alongside preserved originals.
- Processed image files
- Sprite sheets or collages
- Extracted color palettes
By the numbers
- Bundles 9 focused Python image helper scripts plus image_enhancer.py
Files
Image Enhancement Suite
Use this as the primary image toolkit. It now includes the repo's background removal, metadata, comparison, filter, palette, icon, collage, and sprite helpers.
Use This For
- Resize, crop, watermark, compress, and format conversion
- Background removal and quick cleanup
- Image comparisons and metadata inspection
- Palette extraction, icon generation, collages, and sprite sheet assembly
Workflow
1. Start with scripts/image_enhancer.py for general-purpose image work. 2. Use focused helpers when the task is narrow:
background_remover.pyimage_comparison.pyimage_metadata.pyimage_filter.pycolor_palette_extractor.pyicon_generator.pycollage_maker.pysprite_sheet_generator.py
3. Prefer batch operations only when the same transforms should be applied consistently.
Guardrails
- Preserve originals when quality tradeoffs are uncertain.
- Say when a background removal or smart crop is heuristic, not exact.
- Use vector output or SVG-oriented tooling when the request is really illustration, not raster editing.
Bud1(( ferenc
referencesbwspblob�bplist00�]ShowStatusBar[ShowToolbar[ShowTabView_ContainerShowSidebar\WindowBounds[ShowSidebar _{{233, 189}, {920, 436}} #/;R_klmno�
�tslg1Scomp @� @� @� @
referenceslg1Scomp
referenceslsvCblobbbplist00�
XYZ[\]Z_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize � %).38=AFJNR�WvisibleUwidthYascendingZidentifier , Tname�UwidthYascendingWvisibleXubiquity#�"$ �\dateModified�"([dateCreated�+- aTsize�02 s Tkind�57d Ulabel�:<K Wversion�@ Xcomments�CE�^dateLastOpened�CIZshareOwner�CM_shareLastEditor�O"YdateAdded�TV�_invitationStatus##@.#�c�Tname#@02DL`u���������������!)2456?@BCPYZ[gpqsty����������������������������)*+4578KL]fox}^�
referenceslsvpblob�bplist00�
HIJKLMJ_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize �!&+059>BXcommentsUlabelWversion[dateCreatedTsize\dateModifiedTkindTname^dateLastOpened� UindexUwidthYascendingWvisible, �"# d �'( K �,- ��12 a �6- �:;s �? �CD �##@.#�c�Tname#@02DL`u��������������$-39CKMPQR[]_`ajlnopy{}~�����������������������������N
referencesmoDDblob�vz�w�A
referencesmodDblob�vz�w�A
referencesph1Scomp
referencesvSrnlongscriptsbwspblob�bplist00�]ShowStatusBar[ShowToolbar[ShowTabView_ContainerShowSidebar\WindowBounds[ShowSidebar _{{262, 104}, {920, 492}} #/;R_klmno�
�scriptslg1Scompy�scriptslsvCblobbbplist00�
XYZ[\]Z_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize � %).38=AFJNR�WvisibleUwidthYascendingZidentifier , Tname�UwidthYascendingWvisibleXubiquity#�"$ �\dateModified�"([dateCreated�+- aTsize�02 s Tkind�57d Ulabel�:<K Wversion�@ Xcomments�CE�^dateLastOpened�CIZshareOwner�CM_shareLastEditor�O"YdateAdded�TV�_invitationStatus##@.#�c�Tname#@02DL`u���������������!)2456?@BCPYZ[gpqsty����������������������������)*+4578KL]fox}^�scriptslsvpblob�bplist00�
HIJKLMJ_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize �!&+059>BXcommentsUlabelWversion[dateCreatedTsize\dateModifiedTkindTname^dateLastOpened� UindexUwidthYascendingWvisible, �"# d �'( K �,- ��12 a �6- �:;s �? �CD �##@.#�c�Tname#@02DL`u��������������$-39CKMPQR[]_`ajlnopy{}~�����������������������������NscriptsmoDDblob�ïw�AscriptsmodDblob�ïw�Ascriptsph1Scomp�scriptsvSrnlongassetsbwspblob�bplist00�]ShowStatusBar[ShowToolbar[ShowTabView_ContainerShowSidebar\WindowBounds[ShowSidebar _{{233, 189}, {920, 436}} #/;R_klmno�
�assetslg1Scomp assetslsvCblobbbplist00�
XYZ[\]Z_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize � %).38=AFJNR�WvisibleUwidthYascendingZidentifier , Tname�UwidthYascendingWvisibleXubiquity#�"$ �\dateModified�"([dateCreated�+- aTsize�02 s Tkind�57d Ulabel�:<K Wversion�@ Xcomments�CE�^dateLastOpened�CIZshareOwner�CM_shareLastEditor�O"YdateAdded�TV�_invitationStatus##@.#�c�Tname#@02DL`u���������������!)2456?@BCPYZ[gpqsty����������������������������)*+4578KL]fox}^�assetslsvpblob�bplist00�
HIJKLMJ_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize �!&+059>BXcommentsUlabelWversion[dateCreatedTsize\dateModifiedTkindTname^dateLastOpened� UindexUwidthYascendingWvisible, �"# d �'( K �,- ��12 a �6- �:;s �? �CD �##@.#�c�Tname#@02DL`u��������������$-39CKMPQR[]_`ajlnopy{}~�����������������������������NassetsmoDDblob-!��w�AassetsmodDblob-!��w�Aassetsph1Scomp0assetsvSrnlong {920, 492}} #/;R_klmno�(E DSDB `�0@� @� @s_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize �!&+059>BXcommentsUlabelWversion[dateCreatedTsize\dateModifiedTkindTname^dateLastOpened� UindexUwidthYascendingWvisible, �"# d �'( K �,- ��12 a �6- �:;s �? �CD �##@.#�c�Tname#@02DL`u��������������$-39CKMPQR[]_`ajlnopy{}~�����������������������������NassetsmoDDblob-!��w�AassetsmodDblob-!��w�Aassetsph1Scomp0assetsvSrnlong {920, 492}} #/;R_klmno�display_name: 'Image Enhancement Suite'
short_description: 'Process, inspect, and assemble images for practical workflows.'
default_prompt: 'Help me edit or process these images.'
Bud1 rmarks @� @� @� @
watermarksbwspblob�bplist00�]ShowStatusBar[ShowToolbar[ShowTabView_ContainerShowSidebar\WindowBounds[ShowSidebar _{{233, 189}, {920, 436}} #/;R_klmno�
�
watermarkslg1Scomp
watermarkslsvCblobbbplist00�
XYZ[\]Z_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize � %).38=AFJNR�WvisibleUwidthYascendingZidentifier , Tname�UwidthYascendingWvisibleXubiquity#�"$ �\dateModified�"([dateCreated�+- aTsize�02 s Tkind�57d Ulabel�:<K Wversion�@ Xcomments�CE�^dateLastOpened�CIZshareOwner�CM_shareLastEditor�O"YdateAdded�TV�_invitationStatus##@.#�c�Tname#@02DL`u���������������!)2456?@BCPYZ[gpqsty����������������������������)*+4578KL]fox}^�
watermarkslsvpblob�bplist00�
HIJKLMJ_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize �!&+059>BXcommentsUlabelWversion[dateCreatedTsize\dateModifiedTkindTname^dateLastOpened� UindexUwidthYascendingWvisible, �"# d �'( K �,- ��12 a �6- �:;s �? �CD �##@.#�c�Tname#@02DL`u��������������$-39CKMPQR[]_`ajlnopy{}~�����������������������������N
watermarksmoDDblob?yz�w�A
watermarksmodDblob?yz�w�A
watermarksph1Scomp
watermarksvSrnlong EDSDB `�(0@� @� @lumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize �!&+059>BXcommentsUlabelWversion[dateCreatedTsize\dateModifiedTkindTname^dateLastOpened� UindexUwidthYascendingWvisible, �"# d �'( K �,- ��12 a �6- �:;s �? �CD �##@.#�c�Tname#@02DL`u��������������$-39CKMPQR[]_`ajlnopy{}~�����������������������������N
watermarksmoDDblob?yz�w�A
watermarksmodDblob?yz�w�A
watermarksph1Scomp
wate#!/usr/bin/env python3
"""
Background Remover - Remove backgrounds from images using segmentation.
"""
import argparse
import os
from pathlib import Path
from typing import List, Optional, Tuple, Union
import numpy as np
from PIL import Image, ImageFilter, ImageDraw
import cv2
class BackgroundRemover:
"""Remove backgrounds from images using various methods."""
def __init__(self):
"""Initialize the remover."""
self.image = None
self.original = None
self.mask = None
self.filepath = None
def load(self, filepath: str) -> 'BackgroundRemover':
"""Load an image from file."""
self.filepath = filepath
self.image = Image.open(filepath).convert('RGBA')
self.original = self.image.copy()
self.mask = None
return self
def load_array(self, array: np.ndarray) -> 'BackgroundRemover':
"""Load from numpy array."""
if array.shape[-1] == 3:
self.image = Image.fromarray(array).convert('RGBA')
else:
self.image = Image.fromarray(array)
self.original = self.image.copy()
self.mask = None
return self
def remove_background(self, method: str = "auto") -> 'BackgroundRemover':
"""
Remove background using specified method.
Args:
method: "auto", "edge", "grabcut", or "color"
"""
if method == "auto":
# Try to detect best method
method = self._detect_best_method()
if method == "edge":
return self.remove_edges()
elif method == "grabcut":
return self.grabcut()
elif method == "color":
# Try to detect dominant background color
bg_color = self._detect_background_color()
return self.remove_color(bg_color, tolerance=30)
else:
return self.grabcut()
def _detect_best_method(self) -> str:
"""Detect best removal method based on image characteristics."""
img_array = np.array(self.image.convert('RGB'))
# Check if corners have similar colors (likely solid background)
h, w = img_array.shape[:2]
corners = [
img_array[0, 0],
img_array[0, w-1],
img_array[h-1, 0],
img_array[h-1, w-1]
]
# Calculate variance of corner colors
corner_variance = np.var([np.mean(c) for c in corners])
if corner_variance < 100:
return "color"
else:
return "grabcut"
def _detect_background_color(self) -> Tuple[int, int, int]:
"""Detect dominant background color from image edges."""
img_array = np.array(self.image.convert('RGB'))
h, w = img_array.shape[:2]
# Sample colors from edges
edge_pixels = []
edge_pixels.extend(img_array[0, :].tolist()) # Top
edge_pixels.extend(img_array[h-1, :].tolist()) # Bottom
edge_pixels.extend(img_array[:, 0].tolist()) # Left
edge_pixels.extend(img_array[:, w-1].tolist()) # Right
# Find most common color
edge_pixels = np.array(edge_pixels)
mean_color = np.mean(edge_pixels, axis=0).astype(int)
return tuple(mean_color)
def remove_color(self, color: Tuple[int, int, int],
tolerance: int = 20) -> 'BackgroundRemover':
"""
Remove specific color from image.
Args:
color: RGB tuple of color to remove
tolerance: Color matching tolerance (0-255)
"""
if self.image is None:
raise ValueError("No image loaded")
img_array = np.array(self.image.convert('RGB'))
# Calculate distance from target color
diff = np.abs(img_array.astype(int) - np.array(color))
distance = np.sqrt(np.sum(diff ** 2, axis=2))
# Create mask
mask = (distance > tolerance * np.sqrt(3)).astype(np.uint8) * 255
self.mask = Image.fromarray(mask, mode='L')
# Apply mask to image
self.image.putalpha(self.mask)
return self
def remove_edges(self, threshold: int = 50) -> 'BackgroundRemover':
"""
Remove background using edge detection.
Args:
threshold: Edge detection threshold
"""
if self.image is None:
raise ValueError("No image loaded")
img_array = np.array(self.image.convert('RGB'))
# Convert to grayscale
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
# Apply edge detection
edges = cv2.Canny(gray, threshold, threshold * 2)
# Dilate edges
kernel = np.ones((5, 5), np.uint8)
dilated = cv2.dilate(edges, kernel, iterations=2)
# Fill holes
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
mask = np.zeros_like(gray)
if contours:
# Find largest contour
largest = max(contours, key=cv2.contourArea)
cv2.drawContours(mask, [largest], -1, 255, -1)
self.mask = Image.fromarray(mask, mode='L')
self.image.putalpha(self.mask)
return self
def grabcut(self, rect: Tuple[int, int, int, int] = None,
iterations: int = 5) -> 'BackgroundRemover':
"""
Remove background using GrabCut algorithm.
Args:
rect: Bounding rectangle (x, y, width, height) containing foreground
iterations: Number of GrabCut iterations
"""
if self.image is None:
raise ValueError("No image loaded")
img_array = np.array(self.image.convert('RGB'))
h, w = img_array.shape[:2]
# Default rectangle: slightly smaller than full image
if rect is None:
margin = 10
rect = (margin, margin, w - 2*margin, h - 2*margin)
# Initialize mask
mask = np.zeros((h, w), np.uint8)
# GrabCut models
bgd_model = np.zeros((1, 65), np.float64)
fgd_model = np.zeros((1, 65), np.float64)
# Run GrabCut
cv2.grabCut(img_array, mask, rect, bgd_model, fgd_model,
iterations, cv2.GC_INIT_WITH_RECT)
# Create binary mask
mask2 = np.where((mask == 2) | (mask == 0), 0, 255).astype('uint8')
self.mask = Image.fromarray(mask2, mode='L')
self.image.putalpha(self.mask)
return self
def replace_background(self, color: Tuple[int, int, int] = None,
image: str = None) -> 'BackgroundRemover':
"""
Replace transparent background with color or image.
Args:
color: RGB tuple for solid color background
image: Path to background image
"""
if self.image is None:
raise ValueError("No image loaded")
# Ensure we have RGBA
if self.image.mode != 'RGBA':
self.image = self.image.convert('RGBA')
w, h = self.image.size
if image:
# Use image background
bg = Image.open(image).convert('RGB')
bg = bg.resize((w, h), Image.Resampling.LANCZOS)
elif color:
# Use solid color
bg = Image.new('RGB', (w, h), color)
else:
# Default white
bg = Image.new('RGB', (w, h), (255, 255, 255))
# Composite foreground onto background
bg.paste(self.image, (0, 0), self.image)
self.image = bg.convert('RGBA')
return self
def add_shadow(self, offset: Tuple[int, int] = (5, 5),
blur: int = 10, opacity: int = 128) -> 'BackgroundRemover':
"""
Add drop shadow to subject.
Args:
offset: Shadow offset (x, y)
blur: Shadow blur radius
opacity: Shadow opacity (0-255)
"""
if self.image is None or self.mask is None:
return self
w, h = self.image.size
# Create shadow from mask
shadow = self.mask.copy()
# Apply blur
shadow = shadow.filter(ImageFilter.GaussianBlur(blur))
# Create shadow layer
shadow_layer = Image.new('RGBA', (w, h), (0, 0, 0, 0))
shadow_rgba = Image.new('RGBA', (w, h), (0, 0, 0, opacity))
shadow_layer.paste(shadow_rgba, offset, shadow)
# Composite: background + shadow + subject
result = Image.new('RGBA', (w, h), (255, 255, 255, 255))
result = Image.alpha_composite(result, shadow_layer)
result = Image.alpha_composite(result, self.image)
self.image = result
return self
def refine_edges(self, feather: int = 2) -> 'BackgroundRemover':
"""
Refine mask edges with feathering.
Args:
feather: Feather radius in pixels
"""
if self.mask is None:
return self
# Apply gaussian blur to mask for feathering
blurred_mask = self.mask.filter(ImageFilter.GaussianBlur(feather))
# Update alpha channel
if self.image.mode == 'RGBA':
r, g, b, _ = self.image.split()
self.image = Image.merge('RGBA', (r, g, b, blurred_mask))
self.mask = blurred_mask
return self
def expand_mask(self, pixels: int = 2) -> 'BackgroundRemover':
"""Expand mask by specified pixels."""
if self.mask is None:
return self
mask_array = np.array(self.mask)
kernel = np.ones((pixels*2+1, pixels*2+1), np.uint8)
expanded = cv2.dilate(mask_array, kernel, iterations=1)
self.mask = Image.fromarray(expanded, mode='L')
if self.image.mode == 'RGBA':
r, g, b, _ = self.image.split()
self.image = Image.merge('RGBA', (r, g, b, self.mask))
return self
def contract_mask(self, pixels: int = 2) -> 'BackgroundRemover':
"""Contract mask by specified pixels."""
if self.mask is None:
return self
mask_array = np.array(self.mask)
kernel = np.ones((pixels*2+1, pixels*2+1), np.uint8)
contracted = cv2.erode(mask_array, kernel, iterations=1)
self.mask = Image.fromarray(contracted, mode='L')
if self.image.mode == 'RGBA':
r, g, b, _ = self.image.split()
self.image = Image.merge('RGBA', (r, g, b, self.mask))
return self
def save(self, filepath: str, quality: int = 95) -> str:
"""Save the processed image."""
if self.image is None:
raise ValueError("No image to save")
# Determine format from extension
ext = Path(filepath).suffix.lower()
if ext in ['.jpg', '.jpeg']:
# JPEG doesn't support alpha, convert to RGB
rgb_image = Image.new('RGB', self.image.size, (255, 255, 255))
if self.image.mode == 'RGBA':
rgb_image.paste(self.image, mask=self.image.split()[3])
else:
rgb_image.paste(self.image)
rgb_image.save(filepath, quality=quality)
else:
self.image.save(filepath, quality=quality)
return filepath
def get_image(self) -> Image:
"""Get the processed PIL Image."""
return self.image
def get_mask(self) -> Image:
"""Get the mask as PIL Image."""
return self.mask
def batch_process(self, input_dir: str, output_dir: str,
method: str = "auto", **kwargs) -> List[str]:
"""
Process multiple images.
Args:
input_dir: Input directory
output_dir: Output directory
method: Removal method
**kwargs: Additional arguments for removal method
Returns:
List of processed file paths
"""
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
processed = []
extensions = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
for img_file in input_path.iterdir():
if img_file.suffix.lower() not in extensions:
continue
try:
self.load(str(img_file))
if method == "color" and 'color' in kwargs:
self.remove_color(kwargs['color'], kwargs.get('tolerance', 30))
elif method == "grabcut":
self.grabcut(iterations=kwargs.get('iterations', 5))
elif method == "edge":
self.remove_edges(threshold=kwargs.get('threshold', 50))
else:
self.remove_background(method=method)
# Refine if specified
if kwargs.get('feather'):
self.refine_edges(kwargs['feather'])
# Save as PNG to preserve transparency
output_file = output_path / f"{img_file.stem}_nobg.png"
self.save(str(output_file))
processed.append(str(output_file))
print(f"Processed: {img_file.name}")
except Exception as e:
print(f"Error processing {img_file.name}: {e}")
return processed
def parse_color(color_str: str) -> Tuple[int, int, int]:
"""Parse color string like '255,255,255' to tuple."""
parts = color_str.split(',')
return tuple(int(p.strip()) for p in parts)
def main():
parser = argparse.ArgumentParser(description="Background Remover")
parser.add_argument("--input", "-i", help="Input image file")
parser.add_argument("--output", "-o", help="Output image file")
parser.add_argument("--method", choices=['auto', 'color', 'edge', 'grabcut'],
default='auto', help="Removal method")
parser.add_argument("--color", help="Color to remove (R,G,B)")
parser.add_argument("--tolerance", type=int, default=30, help="Color tolerance")
parser.add_argument("--threshold", type=int, default=50, help="Edge threshold")
parser.add_argument("--iterations", type=int, default=5, help="GrabCut iterations")
parser.add_argument("--replace-color", help="Replace background with color (R,G,B)")
parser.add_argument("--replace-image", help="Replace background with image")
parser.add_argument("--feather", type=int, help="Edge feather radius")
parser.add_argument("--expand", type=int, help="Expand mask by pixels")
parser.add_argument("--contract", type=int, help="Contract mask by pixels")
parser.add_argument("--batch", help="Batch process directory")
parser.add_argument("--output-dir", help="Output directory for batch")
args = parser.parse_args()
remover = BackgroundRemover()
if args.batch:
# Batch processing
output_dir = args.output_dir or f"{args.batch}_processed"
kwargs = {
'tolerance': args.tolerance,
'threshold': args.threshold,
'iterations': args.iterations
}
if args.color:
kwargs['color'] = parse_color(args.color)
if args.feather:
kwargs['feather'] = args.feather
processed = remover.batch_process(
args.batch, output_dir,
method=args.method, **kwargs
)
print(f"\nProcessed {len(processed)} images to {output_dir}")
elif args.input:
# Single image processing
remover.load(args.input)
# Apply removal method
if args.method == "color" and args.color:
remover.remove_color(parse_color(args.color), args.tolerance)
elif args.method == "edge":
remover.remove_edges(args.threshold)
elif args.method == "grabcut":
remover.grabcut(iterations=args.iterations)
else:
if args.color:
remover.remove_color(parse_color(args.color), args.tolerance)
else:
remover.remove_background(method=args.method)
# Refinement
if args.feather:
remover.refine_edges(args.feather)
if args.expand:
remover.expand_mask(args.expand)
if args.contract:
remover.contract_mask(args.contract)
# Background replacement
if args.replace_color:
remover.replace_background(color=parse_color(args.replace_color))
elif args.replace_image:
remover.replace_background(image=args.replace_image)
# Save
output = args.output or args.input.rsplit('.', 1)[0] + '_nobg.png'
remover.save(output)
print(f"Saved: {output}")
else:
parser.print_help()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Photo Collage Maker - Create photo collages with grid layouts and custom arrangements.
"""
import argparse
import os
from pathlib import Path
from typing import List, Tuple, Optional, Dict
import glob
import numpy as np
from PIL import Image, ImageDraw, ImageFont
class CollageMaker:
"""Create photo collages from multiple images."""
TEMPLATES = {
'grid_2x2': {'rows': 2, 'cols': 2, 'gap': 10},
'grid_3x3': {'rows': 3, 'cols': 3, 'gap': 10},
'grid_2x3': {'rows': 2, 'cols': 3, 'gap': 10},
'magazine': {
'layout': [
{'x': 0, 'y': 0, 'w': 0.6, 'h': 1.0},
{'x': 0.6, 'y': 0, 'w': 0.4, 'h': 0.5},
{'x': 0.6, 'y': 0.5, 'w': 0.4, 'h': 0.5}
]
},
'pinterest': {
'cols': 3,
'aspect_ratio': 'varied'
},
'polaroid': {
'rows': 2,
'cols': 3,
'padding': 20,
'border': 40
}
}
def __init__(self):
"""Initialize the collage maker."""
self.canvas = None
self.width = 1200
self.height = 800
self.bg_color = (255, 255, 255)
self.gap = 10
self.border = 0
self.border_color = (255, 255, 255)
self.images = []
self.layout = []
def set_canvas(self, width: int, height: int,
bg_color: Tuple[int, int, int] = (255, 255, 255)) -> 'CollageMaker':
"""Set canvas dimensions and background."""
self.width = width
self.height = height
self.bg_color = bg_color
self.canvas = Image.new('RGB', (width, height), bg_color)
return self
def grid(self, rows: int, cols: int, gap: int = 10) -> 'CollageMaker':
"""Set up a grid layout."""
self.gap = gap
self.layout = []
# Calculate cell dimensions
total_gap_w = gap * (cols + 1)
total_gap_h = gap * (rows + 1)
cell_w = (self.width - total_gap_w) // cols
cell_h = (self.height - total_gap_h) // rows
for row in range(rows):
for col in range(cols):
x = gap + col * (cell_w + gap)
y = gap + row * (cell_h + gap)
self.layout.append({
'x': x, 'y': y, 'width': cell_w, 'height': cell_h
})
return self
def template(self, name: str) -> 'CollageMaker':
"""Apply a predefined template."""
if name not in self.TEMPLATES:
raise ValueError(f"Unknown template: {name}")
template = self.TEMPLATES[name]
if 'rows' in template and 'cols' in template:
self.grid(template['rows'], template['cols'], template.get('gap', 10))
elif 'layout' in template:
# Custom layout with proportions
self.layout = []
for slot in template['layout']:
self.layout.append({
'x': int(slot['x'] * self.width),
'y': int(slot['y'] * self.height),
'width': int(slot['w'] * self.width),
'height': int(slot['h'] * self.height)
})
return self
def add_images(self, image_paths: List[str], fit: str = "fill") -> 'CollageMaker':
"""Add multiple images to the layout."""
if not self.layout:
raise ValueError("No layout defined. Call grid() or template() first.")
if not self.canvas:
self.canvas = Image.new('RGB', (self.width, self.height), self.bg_color)
for i, path in enumerate(image_paths):
if i >= len(self.layout):
break
slot = self.layout[i]
self._add_image_to_slot(path, slot, fit)
return self
def add_image(self, path: str, x: int, y: int, width: int, height: int,
fit: str = "fill") -> 'CollageMaker':
"""Add a single image at specific position."""
if not self.canvas:
self.canvas = Image.new('RGB', (self.width, self.height), self.bg_color)
slot = {'x': x, 'y': y, 'width': width, 'height': height}
self._add_image_to_slot(path, slot, fit)
return self
def _add_image_to_slot(self, path: str, slot: Dict, fit: str = "fill"):
"""Add an image to a specific slot."""
try:
img = Image.open(path).convert('RGB')
except Exception as e:
print(f"Error loading {path}: {e}")
return
target_w = slot['width']
target_h = slot['height']
if fit == "fill":
# Crop to fill entire slot
img = self._crop_to_fill(img, target_w, target_h)
elif fit == "fit":
# Fit within slot (may have letterboxing)
img = self._fit_within(img, target_w, target_h)
elif fit == "stretch":
# Stretch to fill
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
self.canvas.paste(img, (slot['x'], slot['y']))
def _crop_to_fill(self, img: Image, target_w: int, target_h: int) -> Image:
"""Crop image to fill target dimensions."""
img_w, img_h = img.size
target_ratio = target_w / target_h
img_ratio = img_w / img_h
if img_ratio > target_ratio:
# Image is wider - crop sides
new_w = int(img_h * target_ratio)
left = (img_w - new_w) // 2
img = img.crop((left, 0, left + new_w, img_h))
else:
# Image is taller - crop top/bottom
new_h = int(img_w / target_ratio)
top = (img_h - new_h) // 2
img = img.crop((0, top, img_w, top + new_h))
return img.resize((target_w, target_h), Image.Resampling.LANCZOS)
def _fit_within(self, img: Image, target_w: int, target_h: int) -> Image:
"""Fit image within target dimensions (letterboxed)."""
img_w, img_h = img.size
ratio = min(target_w / img_w, target_h / img_h)
new_w = int(img_w * ratio)
new_h = int(img_h * ratio)
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Create letterboxed image
result = Image.new('RGB', (target_w, target_h), self.bg_color)
x = (target_w - new_w) // 2
y = (target_h - new_h) // 2
result.paste(img, (x, y))
return result
def set_background(self, color: Tuple[int, int, int] = None,
image: str = None) -> 'CollageMaker':
"""Set background color or image."""
if image:
bg = Image.open(image).convert('RGB')
bg = bg.resize((self.width, self.height), Image.Resampling.LANCZOS)
self.canvas = bg
elif color:
self.bg_color = color
if self.canvas:
# Redraw with new background
new_canvas = Image.new('RGB', (self.width, self.height), color)
self.canvas = new_canvas
return self
def set_border(self, width: int, color: Tuple[int, int, int] = (255, 255, 255)) -> 'CollageMaker':
"""Set border width and color."""
self.border = width
self.border_color = color
return self
def set_gap(self, gap: int) -> 'CollageMaker':
"""Set gap between images."""
self.gap = gap
return self
def rounded_corners(self, radius: int) -> 'CollageMaker':
"""Apply rounded corners to the collage."""
if not self.canvas:
return self
# Create mask with rounded corners
mask = Image.new('L', (self.width, self.height), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle([(0, 0), (self.width, self.height)],
radius=radius, fill=255)
# Apply mask
result = Image.new('RGB', (self.width, self.height), self.bg_color)
result.paste(self.canvas, mask=mask)
self.canvas = result
return self
def add_text(self, text: str, x: int, y: int, font_size: int = 24,
color: Tuple[int, int, int] = (0, 0, 0)) -> 'CollageMaker':
"""Add text to the collage."""
if not self.canvas:
return self
draw = ImageDraw.Draw(self.canvas)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size)
except:
font = ImageFont.load_default()
draw.text((x, y), text, fill=color, font=font)
return self
def save(self, filepath: str, quality: int = 95) -> str:
"""Save the collage."""
if not self.canvas:
raise ValueError("No collage created")
# Add border if specified
if self.border > 0:
bordered = Image.new('RGB',
(self.width + 2 * self.border, self.height + 2 * self.border),
self.border_color)
bordered.paste(self.canvas, (self.border, self.border))
bordered.save(filepath, quality=quality)
else:
self.canvas.save(filepath, quality=quality)
return filepath
def get_image(self) -> Image:
"""Get the PIL Image object."""
return self.canvas
def main():
parser = argparse.ArgumentParser(description="Photo Collage Maker")
parser.add_argument("--images", nargs='+', help="Input image files")
parser.add_argument("--output", "-o", required=True, help="Output file")
parser.add_argument("--grid", help="Grid layout (e.g., 2x2, 3x3)")
parser.add_argument("--template", choices=list(CollageMaker.TEMPLATES.keys()),
help="Use template layout")
parser.add_argument("--gap", type=int, default=10, help="Gap between images")
parser.add_argument("--width", type=int, default=1200, help="Canvas width")
parser.add_argument("--height", type=int, default=800, help="Canvas height")
parser.add_argument("--bg-color", help="Background color (R,G,B)")
parser.add_argument("--bg-image", help="Background image")
parser.add_argument("--fit", choices=['fill', 'fit', 'stretch'],
default='fill', help="Image fit mode")
parser.add_argument("--border", type=int, default=0, help="Border width")
parser.add_argument("--border-color", help="Border color (R,G,B)")
parser.add_argument("--rounded", type=int, help="Rounded corner radius")
args = parser.parse_args()
collage = CollageMaker()
# Set canvas
bg_color = (255, 255, 255)
if args.bg_color:
bg_color = tuple(int(x) for x in args.bg_color.split(','))
collage.set_canvas(args.width, args.height, bg_color)
# Background image
if args.bg_image:
collage.set_background(image=args.bg_image)
# Set layout
if args.grid:
rows, cols = map(int, args.grid.lower().split('x'))
collage.grid(rows, cols, args.gap)
elif args.template:
collage.template(args.template)
else:
# Default to 2x2 grid
collage.grid(2, 2, args.gap)
# Get images
images = []
if args.images:
for pattern in args.images:
if '*' in pattern:
images.extend(glob.glob(pattern))
else:
images.append(pattern)
if not images:
print("No images specified")
parser.print_help()
return
# Add images
collage.add_images(images, fit=args.fit)
# Apply effects
if args.rounded:
collage.rounded_corners(args.rounded)
if args.border > 0:
border_color = (255, 255, 255)
if args.border_color:
border_color = tuple(int(x) for x in args.border_color.split(','))
collage.set_border(args.border, border_color)
# Save
collage.save(args.output)
print(f"Collage saved: {args.output}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Color Palette Extractor - Extract dominant colors from images.
"""
import argparse
import json
from pathlib import Path
from typing import List, Tuple, Dict
import numpy as np
from PIL import Image
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
class ColorPaletteExtractor:
"""Extract color palettes from images."""
def __init__(self):
"""Initialize extractor."""
self.image = None
self.colors = []
self.rgb_colors = []
def load(self, filepath: str) -> 'ColorPaletteExtractor':
"""Load image."""
self.image = Image.open(filepath).convert('RGB')
return self
def extract_colors(self, n_colors: int = 5, sample_size: int = 10000) -> List[str]:
"""
Extract dominant colors using K-means clustering.
Args:
n_colors: Number of colors to extract
sample_size: Number of pixels to sample (for performance)
"""
if not self.image:
raise ValueError("No image loaded")
# Get pixel data
pixels = np.array(self.image)
h, w, c = pixels.shape
pixels_flat = pixels.reshape(-1, 3)
# Sample if image is large
if len(pixels_flat) > sample_size:
indices = np.random.choice(len(pixels_flat), sample_size, replace=False)
pixels_flat = pixels_flat[indices]
# K-means clustering
kmeans = KMeans(n_clusters=n_colors, random_state=42, n_init=10)
kmeans.fit(pixels_flat)
# Get cluster centers (dominant colors)
colors = kmeans.cluster_centers_.astype(int)
self.rgb_colors = [tuple(color) for color in colors]
# Convert to HEX
self.colors = [self._rgb_to_hex(color) for color in colors]
# Sort by frequency
labels = kmeans.labels_
counts = np.bincount(labels)
sorted_indices = np.argsort(-counts)
self.colors = [self.colors[i] for i in sorted_indices]
self.rgb_colors = [self.rgb_colors[i] for i in sorted_indices]
return self.colors
def _rgb_to_hex(self, rgb: Tuple[int, int, int]) -> str:
"""Convert RGB to HEX."""
return '#{:02x}{:02x}{:02x}'.format(rgb[0], rgb[1], rgb[2])
def _hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
"""Convert HEX to RGB."""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def get_color_info(self, color_hex: str) -> Dict:
"""Get color information in multiple formats."""
rgb = self._hex_to_rgb(color_hex)
# Convert to HSL
r, g, b = [x / 255.0 for x in rgb]
max_c = max(r, g, b)
min_c = min(r, g, b)
l = (max_c + min_c) / 2
if max_c == min_c:
h = s = 0
else:
d = max_c - min_c
s = d / (2 - max_c - min_c) if l > 0.5 else d / (max_c + min_c)
if max_c == r:
h = (g - b) / d + (6 if g < b else 0)
elif max_c == g:
h = (b - r) / d + 2
else:
h = (r - g) / d + 4
h /= 6
return {
'hex': color_hex,
'rgb': rgb,
'hsl': (int(h * 360), int(s * 100), int(l * 100))
}
def export_css(self, output: str, prefix: str = 'color') -> str:
"""Export as CSS custom properties."""
css = ":root {\n"
for i, color in enumerate(self.colors, 1):
css += f" --{prefix}-{i}: {color};\n"
css += "}\n"
with open(output, 'w') as f:
f.write(css)
return output
def export_json(self, output: str) -> str:
"""Export as JSON."""
palette = []
for i, (hex_color, rgb) in enumerate(zip(self.colors, self.rgb_colors), 1):
info = self.get_color_info(hex_color)
palette.append({
'name': f'Color {i}',
'hex': hex_color,
'rgb': list(rgb),
'hsl': info['hsl']
})
with open(output, 'w') as f:
json.dump(palette, f, indent=2)
return output
def save_swatch(self, output: str, width: int = 800, height: int = 100) -> str:
"""Generate swatch image."""
n_colors = len(self.colors)
color_width = width // n_colors
swatch = Image.new('RGB', (width, height))
pixels = swatch.load()
for i, rgb in enumerate(self.rgb_colors):
x_start = i * color_width
x_end = (i + 1) * color_width if i < n_colors - 1 else width
for x in range(x_start, x_end):
for y in range(height):
pixels[x, y] = rgb
swatch.save(output)
return output
def visualize(self, output: str) -> str:
"""Create visualization with color info."""
n_colors = len(self.colors)
fig, axes = plt.subplots(1, n_colors, figsize=(n_colors * 2, 3))
if n_colors == 1:
axes = [axes]
for i, (hex_color, rgb) in enumerate(zip(self.colors, self.rgb_colors)):
# Color swatch
axes[i].add_patch(plt.Rectangle((0, 0), 1, 1, color=np.array(rgb)/255))
axes[i].set_xlim(0, 1)
axes[i].set_ylim(0, 1)
axes[i].axis('off')
# Info text
info = self.get_color_info(hex_color)
text = f"{hex_color}\nRGB: {rgb}\nHSL: {info['hsl']}"
axes[i].text(0.5, -0.1, text, ha='center', va='top',
fontsize=8, transform=axes[i].transAxes)
plt.tight_layout()
plt.savefig(output, dpi=300, bbox_inches='tight')
plt.close()
return output
def main():
parser = argparse.ArgumentParser(description="Color Palette Extractor")
parser.add_argument("--input", "-i", required=True, help="Input image")
parser.add_argument("--output", "-o", help="Output file (JSON)")
parser.add_argument("--colors", "-n", type=int, default=5,
help="Number of colors to extract")
parser.add_argument("--css", help="Export CSS file")
parser.add_argument("--swatch", help="Save swatch image")
parser.add_argument("--viz", help="Save visualization")
args = parser.parse_args()
extractor = ColorPaletteExtractor()
extractor.load(args.input)
print(f"Extracting {args.colors} colors from {args.input}...")
colors = extractor.extract_colors(n_colors=args.colors)
print("\nExtracted Colors:")
for i, color in enumerate(colors, 1):
print(f" {i}. {color}")
# Exports
if args.output:
extractor.export_json(args.output)
print(f"\nJSON saved: {args.output}")
if args.css:
extractor.export_css(args.css)
print(f"CSS saved: {args.css}")
if args.swatch:
extractor.save_swatch(args.swatch)
print(f"Swatch saved: {args.swatch}")
if args.viz:
extractor.visualize(args.viz)
print(f"Visualization saved: {args.viz}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Icon Generator - Generate app icons in multiple sizes from a single source image.
"""
import argparse
import os
from pathlib import Path
from typing import List, Tuple, Dict, Optional
from PIL import Image, ImageDraw
class IconGenerator:
"""Generate app icons for multiple platforms."""
# Platform-specific sizes
IOS_SIZES = [
(20, 1), (20, 2), (20, 3), # Notification
(29, 1), (29, 2), (29, 3), # Settings
(40, 1), (40, 2), (40, 3), # Spotlight
(60, 2), (60, 3), # iPhone App
(76, 1), (76, 2), # iPad App
(83.5, 2), # iPad Pro App
(1024, 1), # App Store
]
ANDROID_SIZES = {
'ldpi': 36,
'mdpi': 48,
'hdpi': 72,
'xhdpi': 96,
'xxhdpi': 144,
'xxxhdpi': 192,
'play_store': 512
}
FAVICON_SIZES = [16, 32, 48, 180, 192, 512]
MACOS_SIZES = [16, 32, 64, 128, 256, 512, 1024]
WINDOWS_SIZES = [16, 32, 48, 256]
PWA_SIZES = [72, 96, 128, 144, 152, 192, 384, 512]
def __init__(self):
"""Initialize the generator."""
self.image = None
self.filepath = None
self.rounding = 0
self.padding = 0
self.background = None
def load(self, filepath: str) -> 'IconGenerator':
"""Load source image."""
self.filepath = filepath
self.image = Image.open(filepath).convert('RGBA')
return self
def set_rounding(self, radius_percent: float) -> 'IconGenerator':
"""Set corner rounding as percentage of size."""
self.rounding = radius_percent
return self
def set_padding(self, padding_percent: float) -> 'IconGenerator':
"""Set padding as percentage of size."""
self.padding = padding_percent
return self
def set_background(self, color: Tuple[int, int, int, int]) -> 'IconGenerator':
"""Set background color (RGBA)."""
self.background = color
return self
def _resize_image(self, size: int) -> Image:
"""Resize image to target size with optional effects."""
# Calculate padding
if self.padding > 0:
padding = int(size * self.padding / 100)
inner_size = size - (2 * padding)
else:
padding = 0
inner_size = size
# Resize
resized = self.image.resize((inner_size, inner_size), Image.Resampling.LANCZOS)
# Create canvas
if self.background:
canvas = Image.new('RGBA', (size, size), self.background)
else:
canvas = Image.new('RGBA', (size, size), (0, 0, 0, 0))
# Apply rounding if specified
if self.rounding > 0:
resized = self._apply_rounding(resized, inner_size)
# Paste centered
canvas.paste(resized, (padding, padding), resized)
return canvas
def _apply_rounding(self, img: Image, size: int) -> Image:
"""Apply rounded corners to image."""
radius = int(size * self.rounding / 100)
# Create rounded mask
mask = Image.new('L', (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle([(0, 0), (size, size)], radius=radius, fill=255)
# Apply mask
result = Image.new('RGBA', (size, size), (0, 0, 0, 0))
result.paste(img, (0, 0), mask)
return result
def generate_ios(self, output_dir: str) -> List[str]:
"""Generate iOS app icons."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
generated = []
for base_size, scale in self.IOS_SIZES:
size = int(base_size * scale)
filename = f"Icon-{base_size}@{scale}x.png"
if base_size == int(base_size):
filename = f"Icon-{int(base_size)}@{scale}x.png"
else:
filename = f"Icon-{base_size}@{scale}x.png"
img = self._resize_image(size)
filepath = output_path / filename
img.save(filepath, 'PNG')
generated.append(str(filepath))
return generated
def generate_android(self, output_dir: str) -> List[str]:
"""Generate Android app icons."""
output_path = Path(output_dir)
generated = []
for density, size in self.ANDROID_SIZES.items():
if density == 'play_store':
folder = output_path
else:
folder = output_path / f"mipmap-{density}"
folder.mkdir(parents=True, exist_ok=True)
img = self._resize_image(size)
if density == 'play_store':
filepath = folder / "ic_launcher_playstore.png"
else:
filepath = folder / "ic_launcher.png"
img.save(filepath, 'PNG')
generated.append(str(filepath))
return generated
def generate_favicon(self, output_dir: str) -> List[str]:
"""Generate favicon files."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
generated = []
for size in self.FAVICON_SIZES:
img = self._resize_image(size)
if size == 180:
filename = "apple-touch-icon.png"
elif size in [192, 512]:
filename = f"icon-{size}x{size}.png"
else:
filename = f"favicon-{size}x{size}.png"
filepath = output_path / filename
img.save(filepath, 'PNG')
generated.append(str(filepath))
# Generate ICO file (multi-resolution)
ico_sizes = [16, 32, 48]
ico_images = [self._resize_image(s).convert('RGBA') for s in ico_sizes]
ico_path = output_path / "favicon.ico"
ico_images[0].save(ico_path, format='ICO',
sizes=[(s, s) for s in ico_sizes])
generated.append(str(ico_path))
return generated
def generate_macos(self, output_dir: str) -> List[str]:
"""Generate macOS app icons."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
generated = []
for size in self.MACOS_SIZES:
img = self._resize_image(size)
filename = f"icon_{size}x{size}.png"
filepath = output_path / filename
img.save(filepath, 'PNG')
generated.append(str(filepath))
# @2x variant
if size <= 512:
img_2x = self._resize_image(size * 2)
filename_2x = f"icon_{size}x{size}@2x.png"
filepath_2x = output_path / filename_2x
img_2x.save(filepath_2x, 'PNG')
generated.append(str(filepath_2x))
return generated
def generate_windows(self, output_dir: str) -> List[str]:
"""Generate Windows icons."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
generated = []
# Individual PNGs
for size in self.WINDOWS_SIZES:
img = self._resize_image(size)
filename = f"icon_{size}x{size}.png"
filepath = output_path / filename
img.save(filepath, 'PNG')
generated.append(str(filepath))
# ICO with all sizes
ico_images = [self._resize_image(s).convert('RGBA') for s in self.WINDOWS_SIZES]
ico_path = output_path / "icon.ico"
ico_images[0].save(ico_path, format='ICO',
sizes=[(s, s) for s in self.WINDOWS_SIZES])
generated.append(str(ico_path))
return generated
def generate_pwa(self, output_dir: str) -> List[str]:
"""Generate PWA manifest icons."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
generated = []
for size in self.PWA_SIZES:
img = self._resize_image(size)
filename = f"icon-{size}x{size}.png"
filepath = output_path / filename
img.save(filepath, 'PNG')
generated.append(str(filepath))
return generated
def generate_all(self, output_dir: str) -> Dict[str, List[str]]:
"""Generate icons for all platforms."""
output_path = Path(output_dir)
return {
'ios': self.generate_ios(output_path / 'ios'),
'android': self.generate_android(output_path / 'android'),
'favicon': self.generate_favicon(output_path / 'favicon'),
'macos': self.generate_macos(output_path / 'macos'),
'windows': self.generate_windows(output_path / 'windows'),
'pwa': self.generate_pwa(output_path / 'pwa')
}
def generate_sizes(self, sizes: List[int], output_dir: str,
prefix: str = "icon") -> List[str]:
"""Generate custom sizes."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
generated = []
for size in sizes:
img = self._resize_image(size)
filename = f"{prefix}_{size}x{size}.png"
filepath = output_path / filename
img.save(filepath, 'PNG')
generated.append(str(filepath))
return generated
def generate_single(self, size: int, output: str) -> str:
"""Generate a single icon."""
img = self._resize_image(size)
img.save(output, 'PNG')
return output
def main():
parser = argparse.ArgumentParser(description="Icon Generator")
parser.add_argument("--input", "-i", required=True, help="Input image file")
parser.add_argument("--output-dir", "-o", required=True, help="Output directory")
parser.add_argument("--preset", choices=['ios', 'android', 'favicon', 'macos',
'windows', 'pwa', 'all'],
help="Platform preset")
parser.add_argument("--sizes", nargs='+', type=int, help="Custom sizes")
parser.add_argument("--rounding", type=float, default=0,
help="Corner rounding (percentage)")
parser.add_argument("--padding", type=float, default=0,
help="Padding (percentage)")
parser.add_argument("--background", help="Background color (R,G,B or R,G,B,A)")
args = parser.parse_args()
gen = IconGenerator()
gen.load(args.input)
# Apply options
if args.rounding > 0:
gen.set_rounding(args.rounding)
if args.padding > 0:
gen.set_padding(args.padding)
if args.background:
parts = [int(x) for x in args.background.split(',')]
if len(parts) == 3:
parts.append(255)
gen.set_background(tuple(parts))
# Generate icons
if args.sizes:
generated = gen.generate_sizes(args.sizes, args.output_dir)
print(f"Generated {len(generated)} custom icons")
elif args.preset:
if args.preset == 'all':
result = gen.generate_all(args.output_dir)
total = sum(len(v) for v in result.values())
print(f"Generated {total} icons for all platforms")
for platform, files in result.items():
print(f" {platform}: {len(files)} icons")
elif args.preset == 'ios':
generated = gen.generate_ios(args.output_dir)
print(f"Generated {len(generated)} iOS icons")
elif args.preset == 'android':
generated = gen.generate_android(args.output_dir)
print(f"Generated {len(generated)} Android icons")
elif args.preset == 'favicon':
generated = gen.generate_favicon(args.output_dir)
print(f"Generated {len(generated)} favicon files")
elif args.preset == 'macos':
generated = gen.generate_macos(args.output_dir)
print(f"Generated {len(generated)} macOS icons")
elif args.preset == 'windows':
generated = gen.generate_windows(args.output_dir)
print(f"Generated {len(generated)} Windows icons")
elif args.preset == 'pwa':
generated = gen.generate_pwa(args.output_dir)
print(f"Generated {len(generated)} PWA icons")
else:
# Default to all platforms
result = gen.generate_all(args.output_dir)
total = sum(len(v) for v in result.values())
print(f"Generated {total} icons for all platforms")
print(f"\nOutput directory: {args.output_dir}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Image Comparison Tool - Compare images with SSIM.
"""
import argparse
import cv2
import numpy as np
from PIL import Image
from skimage.metrics import structural_similarity as ssim
import matplotlib.pyplot as plt
class ImageComparisonTool:
"""Compare images."""
def __init__(self):
"""Initialize tool."""
self.img1 = None
self.img2 = None
def load_images(self, path1: str, path2: str) -> 'ImageComparisonTool':
"""Load two images."""
self.img1 = cv2.imread(path1)
self.img2 = cv2.imread(path2)
# Resize if needed
if self.img1.shape != self.img2.shape:
h, w = min(self.img1.shape[0], self.img2.shape[0]), min(self.img1.shape[1], self.img2.shape[1])
self.img1 = cv2.resize(self.img1, (w, h))
self.img2 = cv2.resize(self.img2, (w, h))
return self
def calculate_ssim(self) -> float:
"""Calculate SSIM similarity score."""
gray1 = cv2.cvtColor(self.img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(self.img2, cv2.COLOR_BGR2GRAY)
score, diff = ssim(gray1, gray2, full=True)
self.diff_img = diff
return score
def get_difference_image(self) -> np.ndarray:
"""Get difference heatmap."""
diff = cv2.absdiff(self.img1, self.img2)
return diff
def create_comparison(self, output: str) -> str:
"""Create side-by-side comparison."""
score = self.calculate_ssim()
diff = self.get_difference_image()
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
axes[0].imshow(cv2.cvtColor(self.img1, cv2.COLOR_BGR2RGB))
axes[0].set_title('Image 1')
axes[0].axis('off')
axes[1].imshow(cv2.cvtColor(self.img2, cv2.COLOR_BGR2RGB))
axes[1].set_title('Image 2')
axes[1].axis('off')
axes[2].imshow(diff)
axes[2].set_title(f'Difference (SSIM: {score:.3f})')
axes[2].axis('off')
plt.tight_layout()
plt.savefig(output, dpi=300, bbox_inches='tight')
plt.close()
return output
def main():
parser = argparse.ArgumentParser(description="Image Comparison Tool")
parser.add_argument("--image1", required=True, help="First image")
parser.add_argument("--image2", required=True, help="Second image")
parser.add_argument("--output", "-o", required=True, help="Output comparison image")
args = parser.parse_args()
tool = ImageComparisonTool()
tool.load_images(args.image1, args.image2)
score = tool.calculate_ssim()
print(f"SSIM Similarity Score: {score:.3f}")
if score > 0.95:
print("Images are very similar")
elif score > 0.8:
print("Images are similar")
elif score > 0.5:
print("Images are somewhat different")
else:
print("Images are very different")
tool.create_comparison(args.output)
print(f"\nComparison saved: {args.output}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Image Enhancement Suite - Professional image processing toolkit
Batch resize, crop, watermark, color correct, convert, and compress images.
"""
import io
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import numpy as np
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps
# Try to import OpenCV for advanced features
try:
import cv2
HAS_CV2 = True
except ImportError:
HAS_CV2 = False
class ImageError(Exception):
"""Custom exception for image processing errors."""
pass
@dataclass
class EnhancerConfig:
"""Configuration for image processing."""
default_quality: int = 85
default_format: str = 'jpeg'
preserve_metadata: bool = False
color_profile: str = 'sRGB'
dpi: int = 72
max_dimension: int = 10000
watermark_font: str = 'arial.ttf'
watermark_fallback_font: str = 'DejaVuSans.ttf'
def update(self, settings: Dict[str, Any]) -> None:
for key, value in settings.items():
if hasattr(self, key):
setattr(self, key, value)
# Quality presets for different use cases
PRESETS = {
'web': {'max_width': 1200, 'quality': 85, 'format': 'webp'},
'thumbnail': {'width': 150, 'height': 150, 'crop': True, 'quality': 80},
'preview': {'max_width': 400, 'quality': 70},
'instagram': {'width': 1080, 'height': 1080, 'crop': True, 'quality': 90},
'instagram_portrait': {'width': 1080, 'height': 1350, 'crop': True, 'quality': 90},
'instagram_landscape': {'width': 1080, 'height': 608, 'crop': True, 'quality': 90},
'twitter': {'width': 1200, 'height': 675, 'crop': True, 'quality': 85},
'facebook': {'width': 1200, 'height': 630, 'crop': True, 'quality': 85},
'linkedin': {'width': 1200, 'height': 627, 'crop': True, 'quality': 85},
'print_4x6': {'width': 1800, 'height': 1200, 'dpi': 300, 'quality': 95},
'print_8x10': {'width': 3000, 'height': 2400, 'dpi': 300, 'quality': 95},
'print_11x14': {'width': 4200, 'height': 3300, 'dpi': 300, 'quality': 95},
'hd': {'max_width': 1920, 'max_height': 1080, 'quality': 90},
'4k': {'max_width': 3840, 'max_height': 2160, 'quality': 90},
}
# Position mappings for watermarks
POSITIONS = {
'center': (0.5, 0.5),
'top-left': (0.05, 0.05),
'top-right': (0.95, 0.05),
'bottom-left': (0.05, 0.95),
'bottom-right': (0.95, 0.95),
'top-center': (0.5, 0.05),
'bottom-center': (0.5, 0.95),
}
class ImageEnhancer:
"""
Main class for image enhancement and processing.
Supports chaining operations for fluent API:
ImageEnhancer("photo.jpg").resize(800).sharpen(0.5).save("output.jpg")
"""
def __init__(self, source: Union[str, Path, Image.Image, bytes]):
"""
Initialize with image source.
Args:
source: File path, PIL Image, or bytes
"""
self.config = EnhancerConfig()
self._original: Optional[Image.Image] = None
self._history: List[str] = []
if isinstance(source, (str, Path)):
self._path = Path(source)
self._load_file(self._path)
elif isinstance(source, Image.Image):
self._path = None
self._img = source.copy()
self._original = source.copy()
elif isinstance(source, bytes):
self._path = None
self._img = Image.open(io.BytesIO(source))
self._original = self._img.copy()
else:
raise ImageError(f"Unsupported source type: {type(source)}")
# Ensure RGB mode for most operations
if self._img.mode == 'RGBA':
pass # Keep alpha
elif self._img.mode != 'RGB':
self._img = self._img.convert('RGB')
def _load_file(self, path: Path) -> None:
"""Load image from file."""
if not path.exists():
raise FileNotFoundError(f"Image not found: {path}")
try:
self._img = Image.open(path)
self._img.load() # Force load
self._original = self._img.copy()
except Exception as e:
raise ImageError(f"Failed to load image: {e}")
@property
def size(self) -> Tuple[int, int]:
"""Get current image size (width, height)."""
return self._img.size
@property
def width(self) -> int:
return self._img.size[0]
@property
def height(self) -> int:
return self._img.size[1]
@property
def mode(self) -> str:
return self._img.mode
@property
def format(self) -> Optional[str]:
return self._img.format
def reset(self) -> 'ImageEnhancer':
"""Reset to original image."""
if self._original:
self._img = self._original.copy()
self._history = []
return self
def copy(self) -> 'ImageEnhancer':
"""Create a copy of the enhancer."""
new = ImageEnhancer(self._img.copy())
new._original = self._original.copy() if self._original else None
new._history = self._history.copy()
new.config = self.config
return new
# ==================== RESIZE OPERATIONS ====================
def resize(
self,
width: Optional[int] = None,
height: Optional[int] = None,
max_width: Optional[int] = None,
max_height: Optional[int] = None,
scale: Optional[float] = None,
maintain_aspect: bool = True,
resample: int = Image.Resampling.LANCZOS
) -> 'ImageEnhancer':
"""
Resize the image.
Args:
width: Target width
height: Target height
max_width: Maximum width (fit within)
max_height: Maximum height (fit within)
scale: Scale factor (0.5 = 50%)
maintain_aspect: Maintain aspect ratio
resample: Resampling filter
"""
current_w, current_h = self._img.size
if scale is not None:
new_w = int(current_w * scale)
new_h = int(current_h * scale)
elif max_width or max_height:
# Fit within bounds
new_w, new_h = current_w, current_h
if max_width and current_w > max_width:
ratio = max_width / current_w
new_w = max_width
new_h = int(current_h * ratio)
if max_height and new_h > max_height:
ratio = max_height / new_h
new_h = max_height
new_w = int(new_w * ratio)
elif width and height and not maintain_aspect:
new_w, new_h = width, height
elif width:
ratio = width / current_w
new_w = width
new_h = int(current_h * ratio) if maintain_aspect else (height or current_h)
elif height:
ratio = height / current_h
new_h = height
new_w = int(current_w * ratio) if maintain_aspect else (width or current_w)
else:
return self # No resize needed
self._img = self._img.resize((new_w, new_h), resample)
self._history.append(f"resize({new_w}x{new_h})")
return self
def crop(
self,
width: Optional[int] = None,
height: Optional[int] = None,
position: str = 'center',
box: Optional[Tuple[int, int, int, int]] = None
) -> 'ImageEnhancer':
"""
Crop the image.
Args:
width: Crop width
height: Crop height
position: Crop position ('center', 'top-left', etc.)
box: Explicit crop box (left, top, right, bottom)
"""
if box:
self._img = self._img.crop(box)
self._history.append(f"crop(box={box})")
return self
if not width or not height:
return self
current_w, current_h = self._img.size
# Calculate crop position
if position in POSITIONS:
cx, cy = POSITIONS[position]
else:
cx, cy = 0.5, 0.5
# Calculate crop box
left = int((current_w - width) * cx)
top = int((current_h - height) * cy)
right = left + width
bottom = top + height
# Ensure within bounds
left = max(0, left)
top = max(0, top)
right = min(current_w, right)
bottom = min(current_h, bottom)
self._img = self._img.crop((left, top, right, bottom))
self._history.append(f"crop({width}x{height}, {position})")
return self
def smart_crop(self, width: int, height: int) -> 'ImageEnhancer':
"""
Smart crop that attempts to keep important content.
Falls back to center crop if OpenCV not available.
"""
if not HAS_CV2:
return self.crop(width, height, 'center')
# Convert to OpenCV format
img_array = np.array(self._img)
if len(img_array.shape) == 3:
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
else:
gray = img_array
# Detect edges to find areas of interest
edges = cv2.Canny(gray, 50, 150)
# Find center of mass of edges
moments = cv2.moments(edges)
if moments['m00'] != 0:
cx = int(moments['m10'] / moments['m00'])
cy = int(moments['m01'] / moments['m00'])
else:
cx = self.width // 2
cy = self.height // 2
# Calculate crop box centered on detected center
left = max(0, cx - width // 2)
top = max(0, cy - height // 2)
# Adjust if crop would extend beyond image
if left + width > self.width:
left = self.width - width
if top + height > self.height:
top = self.height - height
left = max(0, left)
top = max(0, top)
self._img = self._img.crop((left, top, left + width, top + height))
self._history.append(f"smart_crop({width}x{height})")
return self
# ==================== WATERMARK OPERATIONS ====================
def watermark(
self,
text: Optional[str] = None,
image: Optional[Union[str, Path, Image.Image]] = None,
position: str = 'bottom-right',
opacity: float = 0.5,
font_size: int = 24,
color: str = 'white',
scale: float = 0.2,
tiled: bool = False,
rotation: float = 0,
padding: int = 10
) -> 'ImageEnhancer':
"""
Add watermark to image.
Args:
text: Text watermark
image: Image watermark (path or PIL Image)
position: Position on image
opacity: Watermark opacity (0-1)
font_size: Font size for text
color: Text color
scale: Scale for image watermark (relative to main image)
tiled: Tile watermark across image
rotation: Rotation angle for text
padding: Padding from edge
"""
# Ensure we have RGBA mode
if self._img.mode != 'RGBA':
self._img = self._img.convert('RGBA')
if text:
self._add_text_watermark(
text, position, opacity, font_size, color, tiled, rotation, padding
)
elif image:
self._add_image_watermark(image, position, opacity, scale, padding)
return self
def _add_text_watermark(
self,
text: str,
position: str,
opacity: float,
font_size: int,
color: str,
tiled: bool,
rotation: float,
padding: int
) -> None:
"""Add text watermark."""
# Create text layer
txt_layer = Image.new('RGBA', self._img.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(txt_layer)
# Try to load font
try:
font = ImageFont.truetype(self.config.watermark_font, font_size)
except (OSError, IOError):
try:
font = ImageFont.truetype(self.config.watermark_fallback_font, font_size)
except (OSError, IOError):
font = ImageFont.load_default()
# Get text size
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
# Calculate opacity
alpha = int(255 * opacity)
# Parse color
if isinstance(color, str):
if color.lower() == 'white':
fill = (255, 255, 255, alpha)
elif color.lower() == 'black':
fill = (0, 0, 0, alpha)
else:
fill = (255, 255, 255, alpha)
else:
fill = (*color[:3], alpha)
if tiled:
# Tile watermark
step_x = text_w + 50
step_y = text_h + 50
for y in range(-text_h, self.height + text_h, step_y):
for x in range(-text_w, self.width + text_w, step_x):
draw.text((x, y), text, font=font, fill=fill)
else:
# Single watermark
if position in POSITIONS:
px, py = POSITIONS[position]
x = int((self.width - text_w) * px)
y = int((self.height - text_h) * py)
else:
x = self.width - text_w - padding
y = self.height - text_h - padding
draw.text((x, y), text, font=font, fill=fill)
# Apply rotation if needed
if rotation != 0:
txt_layer = txt_layer.rotate(rotation, expand=False, center=(self.width // 2, self.height // 2))
# Composite
self._img = Image.alpha_composite(self._img, txt_layer)
self._history.append(f"watermark(text='{text[:20]}...')")
def _add_image_watermark(
self,
watermark: Union[str, Path, Image.Image],
position: str,
opacity: float,
scale: float,
padding: int
) -> None:
"""Add image watermark."""
# Load watermark image
if isinstance(watermark, (str, Path)):
wm = Image.open(watermark)
else:
wm = watermark.copy()
# Ensure RGBA
if wm.mode != 'RGBA':
wm = wm.convert('RGBA')
# Scale watermark
wm_w = int(self.width * scale)
wm_h = int(wm.height * (wm_w / wm.width))
wm = wm.resize((wm_w, wm_h), Image.Resampling.LANCZOS)
# Apply opacity
alpha = wm.split()[3]
alpha = alpha.point(lambda p: int(p * opacity))
wm.putalpha(alpha)
# Calculate position
if position in POSITIONS:
px, py = POSITIONS[position]
x = int((self.width - wm_w) * px)
y = int((self.height - wm_h) * py)
else:
x = self.width - wm_w - padding
y = self.height - wm_h - padding
# Composite
self._img.paste(wm, (x, y), wm)
self._history.append(f"watermark(image, scale={scale})")
# ==================== COLOR ADJUSTMENTS ====================
def brightness(self, factor: float) -> 'ImageEnhancer':
"""
Adjust brightness.
Args:
factor: -1.0 to 1.0 (0 = no change)
"""
enhancer = ImageEnhance.Brightness(self._img)
self._img = enhancer.enhance(1 + factor)
self._history.append(f"brightness({factor})")
return self
def contrast(self, factor: float) -> 'ImageEnhancer':
"""Adjust contrast. Factor: -1.0 to 1.0"""
enhancer = ImageEnhance.Contrast(self._img)
self._img = enhancer.enhance(1 + factor)
self._history.append(f"contrast({factor})")
return self
def saturation(self, factor: float) -> 'ImageEnhancer':
"""Adjust saturation. Factor: -1.0 to 1.0"""
enhancer = ImageEnhance.Color(self._img)
self._img = enhancer.enhance(1 + factor)
self._history.append(f"saturation({factor})")
return self
def sharpen(self, factor: float = 0.5) -> 'ImageEnhancer':
"""Sharpen image. Factor: 0 to 2.0"""
enhancer = ImageEnhance.Sharpness(self._img)
self._img = enhancer.enhance(1 + factor)
self._history.append(f"sharpen({factor})")
return self
def adjust(
self,
brightness: float = 0,
contrast: float = 0,
saturation: float = 0,
sharpen: float = 0
) -> 'ImageEnhancer':
"""Apply multiple adjustments at once."""
if brightness:
self.brightness(brightness)
if contrast:
self.contrast(contrast)
if saturation:
self.saturation(saturation)
if sharpen:
self.sharpen(sharpen)
return self
def auto_enhance(self) -> 'ImageEnhancer':
"""Automatically enhance the image."""
# Auto contrast
self._img = ImageOps.autocontrast(self._img, cutoff=1)
# Slight sharpening
self.sharpen(0.3)
self._history.append("auto_enhance()")
return self
# ==================== FILTERS ====================
def filter(self, filter_name: str) -> 'ImageEnhancer':
"""
Apply a preset filter.
Available: grayscale, sepia, vintage, blur, sharpen, edge_enhance, emboss
"""
filter_name = filter_name.lower()
if filter_name == 'grayscale':
self._img = self._img.convert('L').convert('RGB')
elif filter_name == 'sepia':
self._apply_sepia()
elif filter_name == 'vintage':
self._apply_vintage()
elif filter_name == 'blur':
self._img = self._img.filter(ImageFilter.BLUR)
elif filter_name == 'sharpen':
self._img = self._img.filter(ImageFilter.SHARPEN)
elif filter_name == 'edge_enhance':
self._img = self._img.filter(ImageFilter.EDGE_ENHANCE)
elif filter_name == 'emboss':
self._img = self._img.filter(ImageFilter.EMBOSS)
else:
raise ImageError(f"Unknown filter: {filter_name}")
self._history.append(f"filter({filter_name})")
return self
def _apply_sepia(self) -> None:
"""Apply sepia tone."""
if self._img.mode != 'RGB':
self._img = self._img.convert('RGB')
pixels = np.array(self._img)
r, g, b = pixels[:, :, 0], pixels[:, :, 1], pixels[:, :, 2]
tr = 0.393 * r + 0.769 * g + 0.189 * b
tg = 0.349 * r + 0.686 * g + 0.168 * b
tb = 0.272 * r + 0.534 * g + 0.131 * b
pixels[:, :, 0] = np.clip(tr, 0, 255)
pixels[:, :, 1] = np.clip(tg, 0, 255)
pixels[:, :, 2] = np.clip(tb, 0, 255)
self._img = Image.fromarray(pixels.astype('uint8'))
def _apply_vintage(self) -> None:
"""Apply vintage effect."""
self._apply_sepia()
self.contrast(-0.1)
self.brightness(-0.05)
def blur(self, radius: int = 2) -> 'ImageEnhancer':
"""Apply box blur."""
self._img = self._img.filter(ImageFilter.BoxBlur(radius))
self._history.append(f"blur({radius})")
return self
def gaussian_blur(self, radius: float = 2) -> 'ImageEnhancer':
"""Apply Gaussian blur."""
self._img = self._img.filter(ImageFilter.GaussianBlur(radius))
self._history.append(f"gaussian_blur({radius})")
return self
# ==================== FORMAT & COMPRESSION ====================
def convert(self, format: str) -> 'ImageEnhancer':
"""Convert to specified format."""
self._target_format = format.upper()
self._history.append(f"convert({format})")
return self
def compress(self, quality: int = 85) -> 'ImageEnhancer':
"""Set compression quality."""
self._quality = quality
self._history.append(f"compress({quality})")
return self
def compress_to_size(self, max_kb: int) -> 'ImageEnhancer':
"""Compress to target file size."""
quality = 95
while quality > 10:
buffer = io.BytesIO()
self._img.save(buffer, format='JPEG', quality=quality)
size_kb = buffer.tell() / 1024
if size_kb <= max_kb:
break
quality -= 5
self._quality = quality
self._history.append(f"compress_to_size({max_kb}kb, q={quality})")
return self
def optimize_png(self) -> 'ImageEnhancer':
"""Optimize PNG for smaller file size."""
self._png_optimize = True
self._history.append("optimize_png()")
return self
# ==================== PRESETS ====================
def preset(self, preset_name: str) -> 'ImageEnhancer':
"""Apply a named preset."""
if preset_name not in PRESETS:
raise ImageError(f"Unknown preset: {preset_name}. Available: {list(PRESETS.keys())}")
settings = PRESETS[preset_name]
if settings.get('crop'):
self.smart_crop(settings['width'], settings['height'])
elif 'width' in settings or 'height' in settings:
self.resize(
width=settings.get('width'),
height=settings.get('height'),
max_width=settings.get('max_width'),
max_height=settings.get('max_height')
)
if 'quality' in settings:
self._quality = settings['quality']
if 'format' in settings:
self._target_format = settings['format'].upper()
self._history.append(f"preset({preset_name})")
return self
# ==================== METADATA ====================
def get_metadata(self) -> Dict[str, Any]:
"""Extract image metadata."""
metadata = {
'size': self._img.size,
'mode': self._img.mode,
'format': self._img.format,
}
# Try to get EXIF data
exif = self._img.getexif() if hasattr(self._img, 'getexif') else {}
if exif:
metadata['exif'] = dict(exif)
return metadata
def strip_metadata(self, keep: Optional[List[str]] = None) -> 'ImageEnhancer':
"""Remove metadata from image."""
# Create new image without metadata
data = list(self._img.getdata())
new_img = Image.new(self._img.mode, self._img.size)
new_img.putdata(data)
self._img = new_img
self._history.append("strip_metadata()")
return self
# ==================== SAVE/EXPORT ====================
def save(
self,
path: Union[str, Path],
quality: Optional[int] = None,
format: Optional[str] = None
) -> str:
"""
Save the processed image.
Args:
path: Output file path
quality: JPEG/WebP quality (1-100)
format: Output format (auto-detected from extension if not specified)
Returns:
Path to saved file
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
# Determine format
if format:
fmt = format.upper()
elif hasattr(self, '_target_format'):
fmt = self._target_format
else:
fmt = path.suffix.lstrip('.').upper()
if not fmt:
fmt = 'JPEG'
# Map common format names
fmt_map = {'JPG': 'JPEG', 'WEBP': 'WEBP', 'PNG': 'PNG', 'GIF': 'GIF'}
fmt = fmt_map.get(fmt, fmt)
# Determine quality
q = quality or getattr(self, '_quality', self.config.default_quality)
# Prepare image for saving
save_img = self._img
if fmt == 'JPEG' and save_img.mode == 'RGBA':
# Remove alpha for JPEG
background = Image.new('RGB', save_img.size, (255, 255, 255))
background.paste(save_img, mask=save_img.split()[3])
save_img = background
elif fmt == 'JPEG' and save_img.mode != 'RGB':
save_img = save_img.convert('RGB')
# Save options
save_kwargs = {}
if fmt in ['JPEG', 'WEBP']:
save_kwargs['quality'] = q
if fmt == 'PNG' and getattr(self, '_png_optimize', False):
save_kwargs['optimize'] = True
save_img.save(path, format=fmt, **save_kwargs)
return str(path)
def to_bytes(self, format: str = 'JPEG', quality: int = 85) -> bytes:
"""Export image as bytes."""
buffer = io.BytesIO()
self.save(buffer, format=format, quality=quality)
return buffer.getvalue()
def __repr__(self) -> str:
return f"ImageEnhancer({self.width}x{self.height}, {self.mode})"
# ==================== BATCH PROCESSING ====================
def batch_process(
input_dir: Union[str, Path],
output_dir: Union[str, Path],
operations: List[Tuple[str, Dict[str, Any]]],
formats: Optional[List[str]] = None,
recursive: bool = False,
rename_pattern: Optional[str] = None,
parallel: bool = False
) -> Dict[str, Any]:
"""
Process multiple images with the same operations.
Args:
input_dir: Input directory
output_dir: Output directory
operations: List of (operation_name, kwargs) tuples
formats: File formats to process (default: all image formats)
recursive: Include subdirectories
rename_pattern: Rename pattern (e.g., "{name}_processed_{index:03d}")
parallel: Use parallel processing
Returns:
Results dict with success/failed counts
"""
input_dir = Path(input_dir)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Default formats
if formats is None:
formats = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']
# Find files
patterns = [f"*.{fmt}" for fmt in formats]
if recursive:
files = []
for pattern in patterns:
files.extend(input_dir.rglob(pattern))
else:
files = []
for pattern in patterns:
files.extend(input_dir.glob(pattern))
results = {'success': 0, 'failed': 0, 'errors': []}
for idx, filepath in enumerate(sorted(files)):
try:
enhancer = ImageEnhancer(filepath)
# Apply operations
for op_name, kwargs in operations:
method = getattr(enhancer, op_name, None)
if method and callable(method):
method(**kwargs)
# Determine output path
if rename_pattern:
new_name = rename_pattern.format(
name=filepath.stem,
index=idx,
ext=filepath.suffix.lstrip('.')
)
out_path = output_dir / f"{new_name}{filepath.suffix}"
else:
out_path = output_dir / filepath.name
enhancer.save(out_path)
results['success'] += 1
except Exception as e:
results['failed'] += 1
results['errors'].append({'file': str(filepath), 'error': str(e)})
return results
def generate_sizes(
source: Union[str, Path],
output_dir: Union[str, Path],
widths: List[int],
format: str = 'jpeg'
) -> List[str]:
"""
Generate multiple sizes from a single image.
Args:
source: Source image
output_dir: Output directory
widths: List of widths to generate
format: Output format
Returns:
List of generated file paths
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
source_path = Path(source)
generated = []
enhancer = ImageEnhancer(source)
for width in widths:
new_enhancer = enhancer.copy()
new_enhancer.resize(width=width)
out_path = output_dir / f"{source_path.stem}_{width}.{format}"
new_enhancer.save(out_path)
generated.append(str(out_path))
return generated
def generate_icons(
source: Union[str, Path],
output_dir: Union[str, Path],
sizes: Optional[List[int]] = None
) -> List[str]:
"""
Generate icon sizes from a single image.
Args:
source: Source image (should be square)
output_dir: Output directory
sizes: Icon sizes (default: standard favicon/app icon sizes)
Returns:
List of generated file paths
"""
if sizes is None:
sizes = [16, 32, 48, 64, 128, 180, 192, 256, 512]
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
source_path = Path(source)
generated = []
enhancer = ImageEnhancer(source)
for size in sizes:
new_enhancer = enhancer.copy()
new_enhancer.resize(width=size, height=size, maintain_aspect=False)
out_path = output_dir / f"{source_path.stem}_{size}x{size}.png"
new_enhancer.save(out_path)
generated.append(str(out_path))
return generated
# ==================== CLI ====================
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Image Enhancement Suite')
parser.add_argument('input', help='Input image or directory')
parser.add_argument('-o', '--output', help='Output path')
parser.add_argument('--resize', type=int, help='Resize to width')
parser.add_argument('--quality', type=int, default=85, help='JPEG quality')
parser.add_argument('--watermark', help='Add text watermark')
parser.add_argument('--preset', help='Apply preset')
parser.add_argument('--filter', help='Apply filter')
parser.add_argument('--icons', help='Generate icons to directory')
args = parser.parse_args()
input_path = Path(args.input)
if args.icons:
# Generate icons mode
icons = generate_icons(input_path, args.icons)
print(f"Generated {len(icons)} icons")
elif input_path.is_dir():
# Batch mode
operations = []
if args.resize:
operations.append(('resize', {'width': args.resize}))
if args.watermark:
operations.append(('watermark', {'text': args.watermark}))
if args.preset:
operations.append(('preset', {'preset_name': args.preset}))
if args.filter:
operations.append(('filter', {'filter_name': args.filter}))
output_dir = args.output or 'processed'
results = batch_process(input_path, output_dir, operations)
print(f"Processed {results['success']} images, {results['failed']} failed")
else:
# Single file mode
enhancer = ImageEnhancer(input_path)
if args.resize:
enhancer.resize(width=args.resize)
if args.watermark:
enhancer.watermark(text=args.watermark)
if args.preset:
enhancer.preset(args.preset)
if args.filter:
enhancer.filter(args.filter)
output_path = args.output or input_path.with_stem(f"{input_path.stem}_enhanced")
enhancer.save(output_path, quality=args.quality)
print(f"Saved to: {output_path}")
#!/usr/bin/env python3
"""
Image Filter Lab - Apply artistic filters and effects to images.
"""
import argparse
import os
from pathlib import Path
from typing import List, Tuple, Optional, Callable
import random
import numpy as np
from PIL import Image, ImageEnhance, ImageFilter, ImageOps, ImageDraw
import cv2
class ImageFilterLab:
"""Apply professional filters and effects to images."""
PRESETS = {
'vintage': ['sepia', 'saturation:0.8', 'vignette'],
'film': ['saturation:0.9', 'grain:20', 'contrast:1.1'],
'instagram': ['contrast:1.2', 'temperature:10', 'vignette'],
'noir': ['grayscale', 'contrast:1.4', 'vignette:0.9'],
'warm': ['temperature:20', 'saturation:1.2'],
'cool': ['temperature:-20', 'saturation:0.9'],
'dramatic': ['contrast:1.3', 'saturation:1.1', 'brightness:0.9'],
'dreamy': ['blur:2', 'brightness:1.1', 'contrast:0.9', 'saturation:0.8']
}
def __init__(self):
"""Initialize the filter lab."""
self.image = None
self.original = None
self.filepath = None
def load(self, filepath: str) -> 'ImageFilterLab':
"""Load an image from file."""
self.filepath = filepath
self.image = Image.open(filepath).convert('RGB')
self.original = self.image.copy()
return self
def reset(self) -> 'ImageFilterLab':
"""Reset to original image."""
if self.original:
self.image = self.original.copy()
return self
# Color Filters
def grayscale(self) -> 'ImageFilterLab':
"""Convert to grayscale."""
self.image = ImageOps.grayscale(self.image).convert('RGB')
return self
def sepia(self, intensity: float = 1.0) -> 'ImageFilterLab':
"""Apply sepia tone."""
img_array = np.array(self.image, dtype=np.float32)
# Sepia matrix
sepia_matrix = np.array([
[0.393, 0.769, 0.189],
[0.349, 0.686, 0.168],
[0.272, 0.534, 0.131]
])
sepia_img = np.dot(img_array, sepia_matrix.T)
sepia_img = np.clip(sepia_img, 0, 255).astype(np.uint8)
# Blend with original based on intensity
if intensity < 1.0:
original = np.array(self.image)
sepia_img = (sepia_img * intensity + original * (1 - intensity)).astype(np.uint8)
self.image = Image.fromarray(sepia_img)
return self
def negative(self) -> 'ImageFilterLab':
"""Invert colors."""
self.image = ImageOps.invert(self.image)
return self
def tint(self, color: Tuple[int, int, int], intensity: float = 0.3) -> 'ImageFilterLab':
"""Apply color tint."""
img_array = np.array(self.image, dtype=np.float32)
tint_layer = np.full_like(img_array, color)
blended = img_array * (1 - intensity) + tint_layer * intensity
self.image = Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
return self
# Adjustments
def brightness(self, factor: float) -> 'ImageFilterLab':
"""Adjust brightness. Factor > 1 brightens, < 1 darkens."""
enhancer = ImageEnhance.Brightness(self.image)
self.image = enhancer.enhance(factor)
return self
def contrast(self, factor: float) -> 'ImageFilterLab':
"""Adjust contrast. Factor > 1 increases contrast."""
enhancer = ImageEnhance.Contrast(self.image)
self.image = enhancer.enhance(factor)
return self
def saturation(self, factor: float) -> 'ImageFilterLab':
"""Adjust saturation. Factor > 1 increases, < 1 decreases."""
enhancer = ImageEnhance.Color(self.image)
self.image = enhancer.enhance(factor)
return self
def temperature(self, value: int) -> 'ImageFilterLab':
"""Adjust color temperature. Positive = warm, negative = cool."""
img_array = np.array(self.image, dtype=np.float32)
# Adjust red and blue channels
img_array[:, :, 0] = np.clip(img_array[:, :, 0] + value, 0, 255) # Red
img_array[:, :, 2] = np.clip(img_array[:, :, 2] - value, 0, 255) # Blue
self.image = Image.fromarray(img_array.astype(np.uint8))
return self
def hue(self, shift: int) -> 'ImageFilterLab':
"""Shift hue by degrees (0-360)."""
hsv = self.image.convert('HSV')
h, s, v = hsv.split()
h_array = np.array(h, dtype=np.int32)
h_array = (h_array + shift) % 256
h = Image.fromarray(h_array.astype(np.uint8), mode='L')
self.image = Image.merge('HSV', (h, s, v)).convert('RGB')
return self
# Blur Effects
def blur(self, radius: int = 5) -> 'ImageFilterLab':
"""Apply Gaussian blur."""
self.image = self.image.filter(ImageFilter.GaussianBlur(radius))
return self
def motion_blur(self, size: int = 15, angle: int = 0) -> 'ImageFilterLab':
"""Apply motion blur."""
img_array = np.array(self.image)
# Create motion blur kernel
kernel = np.zeros((size, size))
kernel[size // 2, :] = 1.0 / size
# Rotate kernel for angle
if angle != 0:
M = cv2.getRotationMatrix2D((size / 2, size / 2), angle, 1)
kernel = cv2.warpAffine(kernel, M, (size, size))
blurred = cv2.filter2D(img_array, -1, kernel)
self.image = Image.fromarray(blurred)
return self
def radial_blur(self, amount: int = 10) -> 'ImageFilterLab':
"""Apply radial blur from center."""
# Simplified radial blur using multiple rotated copies
img_array = np.array(self.image, dtype=np.float32)
h, w = img_array.shape[:2]
center = (w // 2, h // 2)
result = img_array.copy()
for i in range(1, amount + 1):
angle = i * 0.5
M = cv2.getRotationMatrix2D(center, angle, 1)
rotated = cv2.warpAffine(img_array, M, (w, h))
result = result * 0.9 + rotated * 0.1
self.image = Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))
return self
# Sharpen
def sharpen(self, factor: float = 1.0) -> 'ImageFilterLab':
"""Sharpen image."""
enhancer = ImageEnhance.Sharpness(self.image)
self.image = enhancer.enhance(1 + factor)
return self
def unsharp_mask(self, radius: int = 2, percent: int = 150, threshold: int = 3) -> 'ImageFilterLab':
"""Apply unsharp mask."""
self.image = self.image.filter(
ImageFilter.UnsharpMask(radius=radius, percent=percent, threshold=threshold)
)
return self
# Artistic Effects
def vintage(self) -> 'ImageFilterLab':
"""Apply vintage filter."""
self.sepia(0.6)
self.saturation(0.8)
self.contrast(1.1)
self.vignette(0.7, 0.4)
return self
def film_grain(self, amount: int = 25) -> 'ImageFilterLab':
"""Add film grain effect."""
img_array = np.array(self.image, dtype=np.float32)
# Generate noise
noise = np.random.normal(0, amount, img_array.shape)
noisy = img_array + noise
self.image = Image.fromarray(np.clip(noisy, 0, 255).astype(np.uint8))
return self
def vignette(self, radius: float = 0.8, intensity: float = 0.5) -> 'ImageFilterLab':
"""Add vignette effect."""
w, h = self.image.size
# Create radial gradient
x = np.linspace(-1, 1, w)
y = np.linspace(-1, 1, h)
X, Y = np.meshgrid(x, y)
distance = np.sqrt(X**2 + Y**2)
# Create vignette mask
vignette_mask = 1 - np.clip((distance - radius) / (1 - radius), 0, 1) * intensity
vignette_mask = np.stack([vignette_mask] * 3, axis=2)
img_array = np.array(self.image, dtype=np.float32)
result = img_array * vignette_mask
self.image = Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))
return self
def posterize(self, levels: int = 4) -> 'ImageFilterLab':
"""Reduce color levels for poster effect."""
self.image = ImageOps.posterize(self.image, levels)
return self
def solarize(self, threshold: int = 128) -> 'ImageFilterLab':
"""Apply solarize effect."""
self.image = ImageOps.solarize(self.image, threshold)
return self
def emboss(self) -> 'ImageFilterLab':
"""Apply emboss effect."""
self.image = self.image.filter(ImageFilter.EMBOSS)
return self
def edge_enhance(self) -> 'ImageFilterLab':
"""Enhance edges."""
self.image = self.image.filter(ImageFilter.EDGE_ENHANCE_MORE)
return self
# Presets
def apply_preset(self, preset: str) -> 'ImageFilterLab':
"""Apply a named preset."""
if preset not in self.PRESETS:
raise ValueError(f"Unknown preset: {preset}")
for operation in self.PRESETS[preset]:
self._apply_operation(operation)
return self
def _apply_operation(self, operation: str):
"""Apply a single operation string."""
if ':' in operation:
name, value = operation.split(':')
value = float(value)
else:
name = operation
value = None
method = getattr(self, name, None)
if method:
if value is not None:
method(value)
else:
method()
# Output
def save(self, filepath: str, quality: int = 95) -> str:
"""Save the processed image."""
self.image.save(filepath, quality=quality)
return filepath
def get_image(self) -> Image:
"""Get the PIL Image object."""
return self.image
# Batch Processing
def batch_process(self, input_dir: str, output_dir: str,
filter_func: Callable = None, preset: str = None) -> List[str]:
"""Process multiple images."""
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
processed = []
extensions = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
for img_file in input_path.iterdir():
if img_file.suffix.lower() not in extensions:
continue
try:
self.load(str(img_file))
if preset:
self.apply_preset(preset)
elif filter_func:
filter_func(self)
output_file = output_path / f"{img_file.stem}_filtered{img_file.suffix}"
self.save(str(output_file))
processed.append(str(output_file))
print(f"Processed: {img_file.name}")
except Exception as e:
print(f"Error processing {img_file.name}: {e}")
return processed
def main():
parser = argparse.ArgumentParser(description="Image Filter Lab")
parser.add_argument("--input", "-i", help="Input image file")
parser.add_argument("--output", "-o", help="Output image file")
# Filter presets
parser.add_argument("--filter", "-f", choices=list(ImageFilterLab.PRESETS.keys()),
help="Apply preset filter")
# Individual filters
parser.add_argument("--grayscale", action="store_true", help="Convert to grayscale")
parser.add_argument("--sepia", action="store_true", help="Apply sepia tone")
parser.add_argument("--negative", action="store_true", help="Invert colors")
parser.add_argument("--vintage", action="store_true", help="Apply vintage filter")
parser.add_argument("--vignette", action="store_true", help="Add vignette")
# Adjustments
parser.add_argument("--brightness", type=float, help="Brightness factor")
parser.add_argument("--contrast", type=float, help="Contrast factor")
parser.add_argument("--saturation", type=float, help="Saturation factor")
parser.add_argument("--temperature", type=int, help="Color temperature adjustment")
parser.add_argument("--sharpen", type=float, help="Sharpen factor")
# Effects
parser.add_argument("--blur", type=int, help="Blur radius")
parser.add_argument("--grain", type=int, help="Film grain amount")
parser.add_argument("--posterize", type=int, help="Posterize levels")
# Batch
parser.add_argument("--batch", help="Batch process directory")
parser.add_argument("--output-dir", help="Output directory for batch")
args = parser.parse_args()
lab = ImageFilterLab()
if args.batch:
output_dir = args.output_dir or f"{args.batch}_filtered"
processed = lab.batch_process(args.batch, output_dir, preset=args.filter)
print(f"\nProcessed {len(processed)} images to {output_dir}")
elif args.input:
lab.load(args.input)
# Apply preset
if args.filter:
lab.apply_preset(args.filter)
# Apply individual filters
if args.grayscale:
lab.grayscale()
if args.sepia:
lab.sepia()
if args.negative:
lab.negative()
if args.vintage:
lab.vintage()
if args.vignette:
lab.vignette()
# Adjustments
if args.brightness:
lab.brightness(args.brightness)
if args.contrast:
lab.contrast(args.contrast)
if args.saturation:
lab.saturation(args.saturation)
if args.temperature:
lab.temperature(args.temperature)
if args.sharpen:
lab.sharpen(args.sharpen)
# Effects
if args.blur:
lab.blur(args.blur)
if args.grain:
lab.film_grain(args.grain)
if args.posterize:
lab.posterize(args.posterize)
# Save
output = args.output or args.input.rsplit('.', 1)[0] + '_filtered.jpg'
lab.save(output)
print(f"Saved: {output}")
else:
parser.print_help()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Image Metadata Tool - Extract, analyze, and manage EXIF metadata from images.
"""
import argparse
import json
import os
from typing import Dict, List, Optional, Any
from datetime import datetime
from pathlib import Path
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
try:
import folium
from folium.plugins import MarkerCluster
FOLIUM_AVAILABLE = True
except ImportError:
FOLIUM_AVAILABLE = False
class ImageMetadata:
"""Extract and manage image EXIF metadata."""
SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.tiff', '.tif', '.png', '.webp', '.heic', '.heif'}
def __init__(self):
"""Initialize the metadata extractor."""
self.image: Optional[Image.Image] = None
self.filepath: Optional[str] = None
self.exif_data: Dict = {}
def load(self, filepath: str) -> 'ImageMetadata':
"""
Load an image file.
Args:
filepath: Path to image file
Returns:
Self for method chaining
"""
self.filepath = filepath
self.image = Image.open(filepath)
self._extract_exif()
return self
def _extract_exif(self):
"""Extract EXIF data from loaded image."""
self.exif_data = {}
if self.image is None:
return
exif = self.image.getexif()
if exif is None:
return
# Standard EXIF tags
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
self.exif_data[tag] = value
# IFD data (more detailed info)
for ifd_id in [0x8769, 0x8825]: # EXIF IFD, GPS IFD
try:
ifd = exif.get_ifd(ifd_id)
if ifd:
tag_mapping = GPSTAGS if ifd_id == 0x8825 else TAGS
for tag_id, value in ifd.items():
tag = tag_mapping.get(tag_id, tag_id)
self.exif_data[tag] = value
except Exception:
pass
def extract(self) -> Dict:
"""
Extract all relevant metadata.
Returns:
Dictionary with organized metadata
"""
if self.image is None:
raise ValueError("No image loaded")
return {
"file": self._get_file_info(),
"camera": self.get_camera_info(),
"settings": self._get_capture_settings(),
"datetime": self.get_datetime(),
"gps": self.get_gps(),
"dimensions": self.get_dimensions()
}
def _get_file_info(self) -> Dict:
"""Get file information."""
if self.filepath is None:
return {}
path = Path(self.filepath)
stat = path.stat()
return {
"name": path.name,
"path": str(path.absolute()),
"size": stat.st_size,
"format": self.image.format if self.image else None
}
def get_camera_info(self) -> Dict:
"""
Get camera and lens information.
Returns:
Dictionary with camera details
"""
info = {}
mappings = {
"make": ["Make"],
"model": ["Model"],
"lens": ["LensModel", "LensInfo"],
"lens_id": ["LensSerialNumber"],
"software": ["Software"],
"serial_number": ["BodySerialNumber"]
}
for key, tags in mappings.items():
for tag in tags:
if tag in self.exif_data:
value = self.exif_data[tag]
if isinstance(value, bytes):
try:
value = value.decode('utf-8').strip('\x00')
except Exception:
continue
info[key] = str(value).strip()
break
return info
def _get_capture_settings(self) -> Dict:
"""Get capture settings (exposure, ISO, etc.)."""
settings = {}
# Exposure time
if "ExposureTime" in self.exif_data:
exp = self.exif_data["ExposureTime"]
if isinstance(exp, tuple):
settings["exposure_time"] = f"{exp[0]}/{exp[1]}" if exp[1] != 1 else str(exp[0])
else:
settings["exposure_time"] = str(exp)
# F-number
if "FNumber" in self.exif_data:
fn = self.exif_data["FNumber"]
if isinstance(fn, tuple):
settings["f_number"] = fn[0] / fn[1] if fn[1] != 0 else fn[0]
else:
settings["f_number"] = float(fn)
# ISO
if "ISOSpeedRatings" in self.exif_data:
iso = self.exif_data["ISOSpeedRatings"]
settings["iso"] = iso[0] if isinstance(iso, tuple) else iso
# Focal length
if "FocalLength" in self.exif_data:
fl = self.exif_data["FocalLength"]
if isinstance(fl, tuple):
settings["focal_length"] = fl[0] / fl[1] if fl[1] != 0 else fl[0]
else:
settings["focal_length"] = float(fl)
if "FocalLengthIn35mmFilm" in self.exif_data:
settings["focal_length_35mm"] = self.exif_data["FocalLengthIn35mmFilm"]
# Exposure program
exposure_programs = {
0: "Not defined", 1: "Manual", 2: "Program", 3: "Aperture priority",
4: "Shutter priority", 5: "Creative", 6: "Action", 7: "Portrait", 8: "Landscape"
}
if "ExposureProgram" in self.exif_data:
settings["exposure_program"] = exposure_programs.get(
self.exif_data["ExposureProgram"], "Unknown"
)
# Metering mode
metering_modes = {
0: "Unknown", 1: "Average", 2: "Center-weighted", 3: "Spot",
4: "Multi-spot", 5: "Pattern", 6: "Partial"
}
if "MeteringMode" in self.exif_data:
settings["metering_mode"] = metering_modes.get(
self.exif_data["MeteringMode"], "Unknown"
)
# Flash
if "Flash" in self.exif_data:
flash = self.exif_data["Flash"]
settings["flash"] = "Flash fired" if (flash & 1) else "No flash"
# White balance
if "WhiteBalance" in self.exif_data:
wb = self.exif_data["WhiteBalance"]
settings["white_balance"] = "Manual" if wb == 1 else "Auto"
return settings
def get_datetime(self) -> Dict:
"""
Get timestamp information.
Returns:
Dictionary with datetime details
"""
dt_info = {}
datetime_tags = {
"original": "DateTimeOriginal",
"digitized": "DateTimeDigitized",
"modified": "DateTime"
}
for key, tag in datetime_tags.items():
if tag in self.exif_data:
value = self.exif_data[tag]
if isinstance(value, bytes):
value = value.decode('utf-8')
dt_info[key] = str(value).strip('\x00')
# Timezone
if "OffsetTimeOriginal" in self.exif_data:
dt_info["timezone"] = self.exif_data["OffsetTimeOriginal"]
return dt_info
def get_gps(self) -> Optional[Dict]:
"""
Get GPS coordinates and related data.
Returns:
Dictionary with GPS data or None if not available
"""
if "GPSLatitude" not in self.exif_data or "GPSLongitude" not in self.exif_data:
return None
def convert_to_degrees(value):
"""Convert GPS coordinates to decimal degrees."""
if isinstance(value, tuple) and len(value) == 3:
d = float(value[0]) if not isinstance(value[0], tuple) else value[0][0] / value[0][1]
m = float(value[1]) if not isinstance(value[1], tuple) else value[1][0] / value[1][1]
s = float(value[2]) if not isinstance(value[2], tuple) else value[2][0] / value[2][1]
return d + m / 60 + s / 3600
return float(value)
try:
lat = convert_to_degrees(self.exif_data["GPSLatitude"])
lon = convert_to_degrees(self.exif_data["GPSLongitude"])
# Apply reference (N/S, E/W)
if self.exif_data.get("GPSLatitudeRef", "N") == "S":
lat = -lat
if self.exif_data.get("GPSLongitudeRef", "E") == "W":
lon = -lon
gps_info = {
"latitude": lat,
"longitude": lon,
"maps_url": f"https://maps.google.com/maps?q={lat},{lon}"
}
# Altitude
if "GPSAltitude" in self.exif_data:
alt = self.exif_data["GPSAltitude"]
if isinstance(alt, tuple):
alt = alt[0] / alt[1] if alt[1] != 0 else alt[0]
gps_info["altitude"] = float(alt)
alt_ref = self.exif_data.get("GPSAltitudeRef", 0)
gps_info["altitude_ref"] = "Below sea level" if alt_ref == 1 else "Above sea level"
# Direction
if "GPSImgDirection" in self.exif_data:
direction = self.exif_data["GPSImgDirection"]
if isinstance(direction, tuple):
direction = direction[0] / direction[1] if direction[1] != 0 else direction[0]
gps_info["direction"] = float(direction)
return gps_info
except Exception:
return None
def get_dimensions(self) -> Dict:
"""
Get image dimensions and orientation.
Returns:
Dictionary with dimension info
"""
if self.image is None:
return {}
dims = {
"width": self.image.width,
"height": self.image.height,
"megapixels": round(self.image.width * self.image.height / 1_000_000, 1)
}
# Orientation
orientation_map = {
1: "Horizontal",
2: "Horizontal (flipped)",
3: "Rotated 180",
4: "Rotated 180 (flipped)",
5: "Rotated 90 CCW (flipped)",
6: "Rotated 90 CW",
7: "Rotated 90 CW (flipped)",
8: "Rotated 90 CCW"
}
if "Orientation" in self.exif_data:
dims["orientation"] = orientation_map.get(
self.exif_data["Orientation"], "Unknown"
)
else:
dims["orientation"] = "Horizontal"
# Resolution
if "XResolution" in self.exif_data:
res = self.exif_data["XResolution"]
dims["resolution_x"] = res[0] / res[1] if isinstance(res, tuple) else res
if "YResolution" in self.exif_data:
res = self.exif_data["YResolution"]
dims["resolution_y"] = res[0] / res[1] if isinstance(res, tuple) else res
res_units = {1: "none", 2: "inch", 3: "cm"}
if "ResolutionUnit" in self.exif_data:
dims["resolution_unit"] = res_units.get(
self.exif_data["ResolutionUnit"], "unknown"
)
return dims
def get_all_exif(self) -> Dict:
"""
Get all raw EXIF data.
Returns:
Dictionary with all EXIF tags
"""
result = {}
for key, value in self.exif_data.items():
if isinstance(value, bytes):
try:
value = value.decode('utf-8').strip('\x00')
except Exception:
value = f"<binary: {len(value)} bytes>"
result[str(key)] = value
return result
def has_location(self) -> bool:
"""
Check if image has GPS data.
Returns:
True if GPS coordinates are present
"""
return self.get_gps() is not None
def strip_metadata(self, output: str, keep_orientation: bool = True) -> str:
"""
Remove EXIF metadata from image.
Args:
output: Output file path
keep_orientation: Whether to preserve orientation tag
Returns:
Output file path
"""
if self.image is None:
raise ValueError("No image loaded")
# Create new image without EXIF
data = list(self.image.getdata())
img_no_exif = Image.new(self.image.mode, self.image.size)
img_no_exif.putdata(data)
# Apply rotation if needed and keeping orientation
if keep_orientation and "Orientation" in self.exif_data:
orientation = self.exif_data["Orientation"]
rotation_map = {
3: 180,
6: -90,
8: 90
}
if orientation in rotation_map:
img_no_exif = img_no_exif.rotate(
rotation_map[orientation], expand=True
)
# Save without EXIF
img_no_exif.save(output, quality=95)
return output
def extract_batch(self, folder: str, recursive: bool = False) -> List[Dict]:
"""
Extract metadata from all images in folder.
Args:
folder: Folder path
recursive: Include subfolders
Returns:
List of metadata dictionaries
"""
results = []
path = Path(folder)
if recursive:
files = path.rglob("*")
else:
files = path.glob("*")
for file in files:
if file.suffix.lower() in self.SUPPORTED_FORMATS:
try:
self.load(str(file))
results.append(self.extract())
except Exception as e:
results.append({
"file": {"name": file.name, "path": str(file), "error": str(e)}
})
return results
def strip_batch(self, input_folder: str, output_folder: str) -> List[Dict]:
"""
Strip metadata from all images in folder.
Args:
input_folder: Input folder path
output_folder: Output folder path
Returns:
List of processed files
"""
results = []
input_path = Path(input_folder)
output_path = Path(output_folder)
output_path.mkdir(parents=True, exist_ok=True)
for file in input_path.glob("*"):
if file.suffix.lower() in self.SUPPORTED_FORMATS:
try:
self.load(str(file))
out_file = output_path / file.name
self.strip_metadata(str(out_file))
results.append({
"input": str(file),
"output": str(out_file),
"success": True
})
except Exception as e:
results.append({
"input": str(file),
"error": str(e),
"success": False
})
return results
def generate_map(self, images: List[Dict], output: str) -> str:
"""
Generate an interactive map from geotagged images.
Args:
images: List of image metadata dictionaries
output: Output HTML file path
Returns:
Output file path
"""
if not FOLIUM_AVAILABLE:
raise ImportError("folium is required for map generation")
# Filter images with GPS
geotagged = [img for img in images if img.get("gps")]
if not geotagged:
raise ValueError("No geotagged images found")
# Calculate center
lats = [img["gps"]["latitude"] for img in geotagged]
lons = [img["gps"]["longitude"] for img in geotagged]
center_lat = sum(lats) / len(lats)
center_lon = sum(lons) / len(lons)
# Create map
m = folium.Map(location=[center_lat, center_lon], zoom_start=10)
# Add markers
marker_cluster = MarkerCluster()
for img in geotagged:
gps = img["gps"]
file_info = img.get("file", {})
camera = img.get("camera", {})
dt = img.get("datetime", {})
popup_html = f"""
<b>{file_info.get('name', 'Unknown')}</b><br>
Camera: {camera.get('model', 'Unknown')}<br>
Date: {dt.get('original', 'Unknown')}<br>
<a href="{gps.get('maps_url', '#')}" target="_blank">Open in Google Maps</a>
"""
folium.Marker(
location=[gps["latitude"], gps["longitude"]],
popup=folium.Popup(popup_html, max_width=300),
icon=folium.Icon(color='blue', icon='camera', prefix='fa')
).add_to(marker_cluster)
marker_cluster.add_to(m)
# Save map
m.save(output)
return output
def to_json(self, output: str) -> str:
"""Export metadata to JSON file."""
data = self.extract()
with open(output, 'w') as f:
json.dump(data, f, indent=2, default=str)
return output
def to_csv(self, output: str) -> str:
"""Export metadata to CSV (requires previous batch extraction)."""
raise NotImplementedError("Use extract_batch() and pandas for CSV export")
def main():
parser = argparse.ArgumentParser(
description="Image Metadata Tool - Extract and manage EXIF metadata"
)
parser.add_argument("--input", "-i", required=True, help="Input image or folder")
parser.add_argument("--output", "-o", help="Output file or folder")
parser.add_argument("--gps", action="store_true", help="Show GPS information")
parser.add_argument("--strip", action="store_true", help="Strip metadata from image")
parser.add_argument("--map", help="Generate location map (output HTML path)")
parser.add_argument("--recursive", "-r", action="store_true", help="Process subfolders")
parser.add_argument("--fields", help="Specific fields to show (comma-separated)")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--all", "-a", action="store_true", help="Show all EXIF data")
args = parser.parse_args()
meta = ImageMetadata()
input_path = Path(args.input)
if input_path.is_dir():
# Batch processing
results = meta.extract_batch(args.input, recursive=args.recursive)
if args.map:
meta.generate_map(results, args.map)
print(f"Map generated: {args.map}")
elif args.strip and args.output:
strip_results = meta.strip_batch(args.input, args.output)
success = sum(1 for r in strip_results if r["success"])
print(f"Stripped metadata from {success}/{len(strip_results)} images")
elif args.json:
print(json.dumps(results, indent=2, default=str))
else:
# Summary
geotagged = sum(1 for r in results if r.get("gps"))
print(f"Processed {len(results)} images")
print(f"Geotagged: {geotagged}")
if args.output:
import pandas as pd
flat_results = []
for r in results:
flat = {
"filename": r.get("file", {}).get("name"),
"camera_make": r.get("camera", {}).get("make"),
"camera_model": r.get("camera", {}).get("model"),
"datetime": r.get("datetime", {}).get("original"),
"latitude": r.get("gps", {}).get("latitude") if r.get("gps") else None,
"longitude": r.get("gps", {}).get("longitude") if r.get("gps") else None,
"width": r.get("dimensions", {}).get("width"),
"height": r.get("dimensions", {}).get("height")
}
flat_results.append(flat)
pd.DataFrame(flat_results).to_csv(args.output, index=False)
print(f"Saved to: {args.output}")
else:
# Single file
meta.load(args.input)
if args.strip:
output = args.output or f"clean_{input_path.name}"
meta.strip_metadata(output)
print(f"Metadata stripped: {output}")
elif args.all:
data = meta.get_all_exif()
if args.json:
print(json.dumps(data, indent=2, default=str))
else:
for key, value in data.items():
print(f"{key}: {value}")
elif args.gps:
gps = meta.get_gps()
if gps:
if args.json:
print(json.dumps(gps, indent=2))
else:
print(f"Latitude: {gps['latitude']}")
print(f"Longitude: {gps['longitude']}")
if 'altitude' in gps:
print(f"Altitude: {gps['altitude']} m")
print(f"Maps: {gps['maps_url']}")
else:
print("No GPS data found")
else:
data = meta.extract()
if args.fields:
fields = args.fields.split(',')
filtered = {}
for field in fields:
if field in data:
filtered[field] = data[field]
data = filtered
if args.json:
print(json.dumps(data, indent=2, default=str))
else:
print(f"\n=== {data['file']['name']} ===\n")
if data.get("camera"):
print("CAMERA")
for k, v in data["camera"].items():
print(f" {k}: {v}")
if data.get("settings"):
print("\nSETTINGS")
for k, v in data["settings"].items():
print(f" {k}: {v}")
if data.get("datetime"):
print("\nDATETIME")
for k, v in data["datetime"].items():
print(f" {k}: {v}")
if data.get("gps"):
print("\nGPS")
for k, v in data["gps"].items():
print(f" {k}: {v}")
if data.get("dimensions"):
print("\nDIMENSIONS")
for k, v in data["dimensions"].items():
print(f" {k}: {v}")
if __name__ == "__main__":
main()
folium>=0.14.0
matplotlib>=3.7.0
numpy>=1.24.0
opencv-python>=4.8.0
pillow>=10.0.0
scikit-image>=0.21.0
scikit-learn>=1.3.0
Related skills
How it compares
Pick image-enhancement-suite for quick raster cleanup in the repo; use a generative image model skill when you need net-new illustrations rather than editing existing screenshots.
FAQ
Which script should image-enhancement-suite run first?
image-enhancement-suite starts most jobs with scripts/image_enhancer.py for resize, crop, watermark, compress, and format conversion. Narrow tasks like background removal or sprite sheets use the dedicated helper modules listed in the skill workflow.
Does image-enhancement-suite overwrite original files?
image-enhancement-suite instructs agents to preserve originals when quality tradeoffs are uncertain. Background removal and smart crops are treated as heuristic, and agents should warn when output may not be pixel-perfect.