| name | photo-editor |
| description | Edit, resize, crop, filter, and optimize images — backgrounds, watermarks, and batch processing. |
Photo Editor
Resize, crop, filter, and optimize images. Pillow for Python, sharp for Node. Clarify intent before starting.
Clarify Intent First
When a user asks to "edit a photo" or "change an image," the request could mean two very different things. Ask before proceeding if it's ambiguous:
- Edit the existing image — crop, resize, recolor, adjust brightness/contrast, add text, remove background, apply filters, watermark, etc. → Use the tools below (Pillow, sharp, OpenCV).
- Generate a new AI image — create something from scratch or heavily reimagine the photo (e.g., "make this photo look like a painting," "put me on a beach," "create a logo from this concept"). → Use image generation tools instead, not this skill.
When to ask
-
"Can you fix this photo?" → Probably editing. Ask what specifically needs fixing.
-
"Make this look better" → Ambiguous. Ask: "Do you want me to adjust the existing photo (brightness, contrast, cropping, etc.) or generate a new version with AI?"
-
"Change the background" → Could be either. Ask: "Should I remove the current background (I can make it transparent or a solid color), or do you want an AI-generated scene behind you?"
-
"Make a profile picture from this" → Likely crop/resize, but could mean AI enhancement. Clarify.
Don't ask when it's obvious
-
"Crop this to 1080x1080" → Just crop it.
-
"Make this a PNG" → Just convert it.
-
"Remove the background" → Use rembg.
-
"Generate a photo of a sunset" → No existing photo to edit — use image generation.
Tool Selection
| Tool | Use when | Install |
|---|---|---|
| Pillow | Default: resize, crop, filters, text, format conversion | pip install Pillow |
| OpenCV | Computer vision: face detection, perspective transform, inpainting, contours | pip install opencv-python numpy |
| sharp (Node) | High-volume pipelines — 4-5x faster than Pillow (libvips-backed) | npm install sharp |
| rembg | AI background removal | pip install rembg |
| ImageMagick | CLI batch ops, 200+ formats. Use the magick command, e.g. magick -size 100x100 xc:blue test2.jpg. | apt install imagemagick |
Open — ALWAYS Fix Orientation First
from PIL import Image, ImageOps
img = Image.open("photo.jpg")
img = ImageOps.exif_transpose(img) \
Resize & Crop
from PIL import Image, ImageOps
img.thumbnail((1080, 1080), Image.Resampling.LANCZOS) \
thumb = ImageOps.fit(img, (300, 300), Image.Resampling.LANCZOS, centering=(0.5, 0.5))
padded = ImageOps.pad(img, (1920, 1080), color=(0, 0, 0))
stretched = img.resize((800, 600), Image.Resampling.LANCZOS)
half = img.resize((img.width // 2, img.height // 2), Image.Resampling.LANCZOS)
cropped = img.crop((100, 50, 900, 650))
Resampling filters: LANCZOSfor photo downscale (best quality),BICUBICfor upscale,NEAREST for pixel art/icons (no smoothing).
Face-Aware Cropping
For portraits and headshots, detect the face first and crop around it instead of guessing coordinates. This produces much better results for profile pictures.
import cv2
import numpy as np
from PIL import Image, ImageOps
img = Image.open("portrait.jpg")
img = ImageOps.exif_transpose(img)
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(100, 100))
if len(faces) > 0:
fx, fy, fw, fh = max(faces, key=lambda f: f[2] * f[3])
face_cx = fx + fw // 2
face_cy = fy + fh // 2
crop_size = min(img.width, img.height, fh * 3)
left = max(0, face_cx - crop_size // 2)
top = max(0, face_cy - int(crop_size * 0.35)) \
right = left + crop_size
bottom = top + crop_size
if right > img.width:
left -= (right - img.width)
right = img.width
if bottom > img.height:
top -= (bottom - img.height)
bottom = img.height
left = max(0, left)
top = max(0, top)
cropped = img.crop((left, top, right, bottom))
profile = cropped.resize((800, 800), Image.Resampling.LANCZOS)
profile.save("profile_800x800.jpg", quality=92, optimize=True)
else:
profile = ImageOps.fit(img, (800, 800), Image.Resampling.LANCZOS, centering=(0.5, 0.4))
profile.save("profile_800x800.jpg", quality=92, optimize=True)
Tips
centering=(0.5, 0.4) in the fallback biases the crop slightly toward the top — better for portraits than dead center.
- For group photos with multiple faces, you may want to fit all detected faces in the crop instead of picking the largest.
Color & Exposure
from PIL import ImageEnhance, ImageOps
img = ImageEnhance.Brightness(img).enhance(1.15)
img = ImageEnhance.Contrast(img).enhance(1.2)
img = ImageEnhance.Color(img).enhance(1.1) \
img = ImageEnhance.Sharpness(img).enhance(1.5)
gray = ImageOps.grayscale(img)
inverted = ImageOps.invert(img.convert("RGB"))
auto = ImageOps.autocontrast(img, cutoff=1) \
equalized = ImageOps.equalize(img) \
Filters
from PIL import ImageFilter
img.filter(ImageFilter.GaussianBlur(radius=5))
img.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3)) \
img.filter(ImageFilter.BoxBlur(10))
img.filter(ImageFilter.FIND_EDGES)
img.filter(ImageFilter.MedianFilter(size=3)) \
Watermark / Logo Removal
Use OpenCV's cv2.inpaint()— it fills a masked region by sampling surrounding pixels, producing seamless results. Do not use pixel-by-pixelgetpixel/putpixel loops — they are slow and produce visible artifacts.
Step 1: Find the watermark boundaries
Always inspect the image at full resolution first. Watermarks are often much larger than they appear in thumbnails. Save a crop of the watermark region to verify coordinates before attempting removal.
import cv2
import numpy as np
from PIL import Image, ImageOps
img = Image.open("photo.jpg")
img = ImageOps.exif_transpose(img)
w, h = img.size
debug = img.crop((0, 0, min(w, 1000), min(h, 500)))
debug.save("debug_watermark_area.jpg")
Step 2: Create a mask and inpaint
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
hsv = cv2.cvtColor(cv_img, cv2.COLOR_BGR2HSV)
lower_blue = np.array([90, 40, 40])
upper_blue = np.array([130, 255, 255])
mask = cv2.inRange(hsv, lower_blue, upper_blue)
roi_gray = cv2.cvtColor(cv_img[:wm_h, :wm_w], cv2.COLOR_BGR2GRAY)
_, dark_mask = cv2.threshold(roi_gray, 80, 255, cv2.THRESH_BINARY_INV)
mask[:wm_h, :wm_w] = cv2.bitwise_or(mask[:wm_h, :wm_w], dark_mask)
mask = np.zeros(cv_img.shape[:2], dtype=np.uint8)
mask[0:wm_h, 0:wm_w] = 255 \
kernel = np.ones((5, 5), np.uint8)
mask = cv2.dilate(mask, kernel, iterations=2)
result = cv2.inpaint(cv_img, mask, inpaintRadius=7, flags=cv2.INPAINT_TELEA)
result_pil = Image.fromarray(cv2.cvtColor(result, cv2.COLOR_BGR2RGB))
result_pil.save("clean.jpg", quality=92, optimize=True)
Step 3: Verify the result
verify = result_pil.crop((0, 0, min(w, 1000), min(h, 500)))
verify.save("debug_watermark_removed.jpg")
Tips (2)
-
For watermarks on uniform backgrounds (studio portraits, product photos), INPAINT_TELEAwithinpaintRadius=5-10 works well.
-
For watermarks over textured areas (landscapes, fabric), use INPAINT_NS with a larger radius (10-15).
-
If the watermark is semi-transparent, color-based masking (Option A) is more precise than a rectangular region mask.
-
Always verify at full resolution — artifacts invisible in thumbnails may be obvious when zoomed in.
Text & Watermark (Adding)
from PIL import Image, ImageDraw, ImageFont
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("DejaVuSans-Bold.ttf", 48) \
except OSError:
font = ImageFont.load_default() \
draw.text((50, 50), "Caption", font=font, fill="white",
stroke_width=3, stroke_fill="black")
bbox = draw.textbbox((0, 0), "Centered", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((img.width - tw) // 2, (img.height - th) // 2), "Centered", font=font, fill="white")
logo = Image.open("logo.png").convert("RGBA")
logo.thumbnail((img.width // 5, img.height // 5))
alpha = logo.split()[3].point(lambda p: int(p * 0.4))
logo.putalpha(alpha)
pos = (img.width - logo.width - 20, img.height - logo.height - 20)
img.paste(logo, pos, logo) \
Save & Optimize
img.convert("RGB").save("out.jpg", quality=85, optimize=True, progressive=True)
img.save("out.png", optimize=True, compress_level=9)
img.save("out.webp", quality=85, method=6) \
img.save("out.avif", quality=75) \
clean = Image.new(img.mode, img.size)
clean.putdata(list(img.getdata()))
clean.save("stripped.jpg", quality=85)
Quality guide: JPEG/WebP 85 = sweet spot. 90+ = diminishing returns. <70 = visible artifacts. Never re-save JPEGs repeatedly — each save degrades (generation loss).
Batch Processing
from pathlib import Path
from PIL import Image, ImageOps
out = Path("optimized"); out.mkdir(exist_ok=True)
for p in Path("photos").glob("*.[jJ][pP]*[gG]"): \
img = ImageOps.exif_transpose(Image.open(p))
img.thumbnail((1920, 1920), Image.Resampling.LANCZOS)
img.convert("RGB").save(out / f"{p.stem}.webp", quality=85, method=6)
sharp (Node.js — use for high throughput)
const sharp = require('sharp');
await sharp('in.jpg')
.rotate()
.resize(1080, 1080, { fit: 'cover', position: 'center' })
.webp({ quality: 85 })
.toFile('out.webp');
await sharp('photo.jpg')
.composite([{ input: 'logo.png', gravity: 'southeast' }])
.toFile('watermarked.jpg');
sharp strips all metadata by default. Use .withMetadata() to preserve EXIF/ICC.
OpenCV (when Pillow isn't enough)
import cv2
img = cv2.imread("in.jpg") \
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5)
for (x, y, w, h) in faces:
cv2.rectangle(img, (x, y), (x+w, y+h), (0, 255, 0), 2)
cv2.imwrite("out.jpg", img)
import numpy as np
cv_img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
pil_img = Image.fromarray(cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB))
Bulk Pixel Manipulation — Use numpy, Not getpixel/putpixel
Never loop over pixels with getpixel()/putpixel() for large regions — it is extremely slow (minutes for a full image). Convert to a numpy array, operate on the array, then convert back.
import numpy as np
from PIL import Image
img = Image.open("photo.jpg").convert("RGB")
arr = np.array(img) \
bg_color = arr[50, -100, :] \
arr[0:300, 0:800, :] = bg_color \
alpha = np.linspace(1, 0, 100).reshape(1, 100, 1) \
region = arr[0:300, 700:800, :]
bg_strip = np.full_like(region, bg_color)
arr[0:300, 700:800, :] = (region * (1 - alpha) + bg_strip * alpha).astype(np.uint8)
result = Image.fromarray(arr)
Speed comparison: putpixel on a 700x250 region = ~175,000 calls = 30+ seconds. numpy array slice = instant.
Platform Dimensions
| Platform | Size | Ratio |
|---|---|---|
| Instagram post | 1080x1080 | 1:1 |
| Instagram story / TikTok | 1080x1920 | 9:16 |
| LinkedIn profile photo | 400x400 | 1:1 |
| LinkedIn banner | 1584x396 | 4:1 |
| LinkedIn post | 1200x627 | 1.91:1 |
| Twitter/X profile | 400x400 | 1:1 |
| Twitter/X post | 1200x675 | 16:9 |
| Facebook profile | 320x320 | 1:1 |
| Facebook cover | 851x315 | 2.7:1 |
| YouTube thumbnail | 1280x720 | 16:9 |
| WhatsApp profile | 500x500 | 1:1 |
| Open Graph (link preview) | 1200x630 | 1.91:1 |
Debug Workflow
When edits don't look right, follow this process instead of re-running the whole pipeline blindly:
-
Save a debug crop of the target area before and after processing. View both to confirm what changed.
-
Work at full resolution first. Watermarks and artifacts that look small in a thumbnail can be large at native resolution. Always inspect at the original size before resizing.
-
Save intermediate results. After each major processing step (watermark removal, crop, resize), save a checkpoint image so you can identify which step introduced a problem.
-
Spot-check specific pixels to verify processing took effect:
for (x, y) in [(60, 50), (200, 130), (400, 100)]:
print(f"({x},{y}): {img.getpixel((x, y))}")
Gotchas
-
img.crop()box is(left, top, right, bottom) — absolute coords, NOT(x, y, width, height)
-
thumbnail()mutates in place and returnsNone — don't doimg = img.thumbnail(...)
-
Paste with transparency needs the image as the third (mask) arg: bg.paste(fg, pos, fg)
-
Palette mode ("P") breaks many filters — img.convert("RGB") first
-
Fonts: ImageFont.truetypeneeds a real font file. Linux:/usr/share/fonts/truetype/dejavu/. Ship a.ttf with your code for portability.
-
OpenCV needs numpy — always pip install opencv-python numpy together
-
OpenCV uses BGR, Pillow uses RGB — convert when switching between them or colors will be wrong