1 ---
2 name: photo-editor
3 description: Edit, resize, crop, filter, and optimize images — backgrounds, watermarks, and batch processing.
4 ---
5
6 # Photo Editor
7
8 Resize, crop, filter, and optimize images. Pillow for Python, sharp for Node. Clarify intent before starting.
9
10 ## Clarify Intent First
11
12 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:
13
14 1. **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).
15 2. **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.
16
17 ### When to ask
18
19 - "Can you fix this photo?" → Probably editing. Ask what specifically needs fixing.
20 - "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?"
21
22 - "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?"
23 - "Make a profile picture from this" → Likely crop/resize, but could mean AI enhancement. Clarify.
24
25 #### Don't ask when it's obvious
26
27 - "Crop this to 1080x1080" → Just crop it.
28 - "Make this a PNG" → Just convert it.
29
30 - "Remove the background" → Use rembg.
31 - "Generate a photo of a sunset" → No existing photo to edit — use image generation.
32
33 ## Tool Selection
34
35 | Tool | Use when | Install |
36
37 |---|---|---|
38
39 | **Pillow** | Default: resize, crop, filters, text, format conversion | `pip install Pillow` |
40
41 | **OpenCV** | Computer vision: face detection, perspective transform, inpainting, contours | `pip install opencv-python numpy` |
42
43 | **sharp** (Node) | High-volume pipelines — 4-5x faster than Pillow (libvips-backed) | `npm install sharp` |
44
45 | **rembg** | AI background removal | `pip install rembg` |
46
47 | **ImageMagick** | CLI batch ops, 200+ formats. Use the `magick` command, e.g. `magick -size 100x100 xc:blue test2.jpg`. | `apt install imagemagick` |
48
49 ## Open — ALWAYS Fix Orientation First
50
51 ```python
52
53 from PIL import Image, ImageOps
54
55 img = Image.open("photo.jpg")
56
57 img = ImageOps.exif_transpose(img) \# CRITICAL: applies EXIF rotation, then strips tag
58
59 # Without this, phone photos appear sideways after processing
60
61 ```
62
63 ## Resize & Crop
64
65 ```python
66
67 from PIL import Image, ImageOps
68
69 # --- Fit inside box, keep aspect ratio (shrink only) ---
70
71 img.thumbnail((1080, 1080), Image.Resampling.LANCZOS) \# modifies in place
72
73 # --- Exact size, keep aspect, center-crop overflow (best for thumbnails) ---
74
75 thumb = ImageOps.fit(img, (300, 300), Image.Resampling.LANCZOS, centering=(0.5, 0.5))
76
77 # --- Exact size, keep aspect, pad with color (letterbox) ---
78
79 padded = ImageOps.pad(img, (1920, 1080), color=(0, 0, 0))
80
81 # --- Exact size, ignore aspect (will distort) ---
82
83 stretched = img.resize((800, 600), Image.Resampling.LANCZOS)
84
85 # --- Scale by factor ---
86
87 half = img.resize((img.width // 2, img.height // 2), Image.Resampling.LANCZOS)
88
89 # --- Manual crop (left, upper, right, lower) — NOT (x, y, w, h) ---
90
91 cropped = img.crop((100, 50, 900, 650))
92
93 ```
94
95 **Resampling filters:** `LANCZOS`for photo downscale (best quality),`BICUBIC`for upscale,`NEAREST` for pixel art/icons (no smoothing).
96
97 ## Face-Aware Cropping
98
99 For portraits and headshots, detect the face first and crop around it instead of guessing coordinates. This produces much better results for profile pictures.
100
101 ```python
102
103 import cv2
104
105 import numpy as np
106
107 from PIL import Image, ImageOps
108
109 img = Image.open("portrait.jpg")
110
111 img = ImageOps.exif_transpose(img)
112
113 cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
114
115 gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
116
117 cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
118
119 faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(100, 100))
120
121 if len(faces) > 0:
122
123 # Use the largest detected face
124
125 fx, fy, fw, fh = max(faces, key=lambda f: f[2] * f[3])
126
127 face_cx = fx + fw // 2
128
129 face_cy = fy + fh // 2
130
131 # Square crop centered on face with padding (3x face height for head+shoulders)
132
133 crop_size = min(img.width, img.height, fh * 3)
134
135 left = max(0, face_cx - crop_size // 2)
136
137 top = max(0, face_cy - int(crop_size * 0.35)) \# face in upper third
138
139 right = left + crop_size
140
141 bottom = top + crop_size
142
143 # Clamp to image bounds
144
145 if right > img.width:
146
147 left -= (right - img.width)
148
149 right = img.width
150
151 if bottom > img.height:
152
153 top -= (bottom - img.height)
154
155 bottom = img.height
156
157 left = max(0, left)
158
159 top = max(0, top)
160
161 cropped = img.crop((left, top, right, bottom))
162
163 profile = cropped.resize((800, 800), Image.Resampling.LANCZOS)
164
165 profile.save("profile_800x800.jpg", quality=92, optimize=True)
166
167 else:
168
169 # Fallback: center crop
170
171 profile = ImageOps.fit(img, (800, 800), Image.Resampling.LANCZOS, centering=(0.5, 0.4))
172
173 profile.save("profile_800x800.jpg", quality=92, optimize=True)
174
175 ```
176
177 ### Tips
178
179 - `centering=(0.5, 0.4)` in the fallback biases the crop slightly toward the top — better for portraits than dead center.
180 - For group photos with multiple faces, you may want to fit all detected faces in the crop instead of picking the largest.
181
182 ## Color & Exposure
183
184 ```python
185
186 from PIL import ImageEnhance, ImageOps
187
188 # --- Enhancers: 1.0 = unchanged, <1 less, >1 more ---
189
190 img = ImageEnhance.Brightness(img).enhance(1.15)
191
192 img = ImageEnhance.Contrast(img).enhance(1.2)
193
194 img = ImageEnhance.Color(img).enhance(1.1) \# saturation
195
196 img = ImageEnhance.Sharpness(img).enhance(1.5)
197
198 # --- Quick ops ---
199
200 gray = ImageOps.grayscale(img)
201
202 inverted = ImageOps.invert(img.convert("RGB"))
203
204 auto = ImageOps.autocontrast(img, cutoff=1) \# stretch histogram, clip 1% extremes
205
206 equalized = ImageOps.equalize(img) \# flatten histogram
207
208 ```
209
210 ## Filters
211
212 ```python
213
214 from PIL import ImageFilter
215
216 img.filter(ImageFilter.GaussianBlur(radius=5))
217
218 img.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3)) \# better than SHARPEN
219
220 img.filter(ImageFilter.BoxBlur(10))
221
222 img.filter(ImageFilter.FIND_EDGES)
223
224 img.filter(ImageFilter.MedianFilter(size=3)) \# denoise, removes salt-and-pepper
225
226 ```
227
228 ## Watermark / Logo Removal
229
230 Use OpenCV's `cv2.inpaint()`— it fills a masked region by sampling surrounding pixels, producing seamless results. **Do not use pixel-by-pixel`getpixel`/`putpixel` loops** — they are slow and produce visible artifacts.
231
232 ### Step 1: Find the watermark boundaries
233
234 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.
235
236 ```python
237
238 import cv2
239
240 import numpy as np
241
242 from PIL import Image, ImageOps
243
244 img = Image.open("photo.jpg")
245
246 img = ImageOps.exif_transpose(img)
247
248 w, h = img.size
249
250 # Save a debug crop of the suspected watermark area to verify its extent
251
252 debug = img.crop((0, 0, min(w, 1000), min(h, 500)))
253
254 debug.save("debug_watermark_area.jpg")
255
256 # IMPORTANT: View this debug image to confirm where the watermark actually is
257
258 # before proceeding. Guessing coordinates wastes iterations.
259
260 ```
261
262 ### Step 2: Create a mask and inpaint
263
264 ```python
265
266 cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
267
268 # --- Option A: Color-based mask (best for colored logos on neutral backgrounds) ---
269
270 hsv = cv2.cvtColor(cv_img, cv2.COLOR_BGR2HSV)
271
272 # Example: detect blue watermark pixels (adjust ranges for your watermark color)
273
274 lower_blue = np.array([90, 40, 40])
275
276 upper_blue = np.array([130, 255, 255])
277
278 mask = cv2.inRange(hsv, lower_blue, upper_blue)
279
280 # Also catch dark text pixels in the watermark region
281
282 roi_gray = cv2.cvtColor(cv_img[:wm_h, :wm_w], cv2.COLOR_BGR2GRAY)
283
284 _, dark_mask = cv2.threshold(roi_gray, 80, 255, cv2.THRESH_BINARY_INV)
285
286 # Combine: place dark_mask into the full-size mask
287
288 mask[:wm_h, :wm_w] = cv2.bitwise_or(mask[:wm_h, :wm_w], dark_mask)
289
290 # --- Option B: Region-based mask (when you know the bounding box) ---
291
292 # Simpler but removes everything in the box, not just the watermark pixels
293
294 mask = np.zeros(cv_img.shape[:2], dtype=np.uint8)
295
296 mask[0:wm_h, 0:wm_w] = 255 \# fill the entire watermark region
297
298 # Dilate the mask slightly to catch anti-aliased edges
299
300 kernel = np.ones((5, 5), np.uint8)
301
302 mask = cv2.dilate(mask, kernel, iterations=2)
303
304 # Inpaint — fills masked area using surrounding pixel data
305
306 result = cv2.inpaint(cv_img, mask, inpaintRadius=7, flags=cv2.INPAINT_TELEA)
307
308 # INPAINT_TELEA: fast marching method (best for most cases)
309
310 # INPAINT_NS: Navier-Stokes (better for large regions, slower)
311
312 result_pil = Image.fromarray(cv2.cvtColor(result, cv2.COLOR_BGR2RGB))
313
314 result_pil.save("clean.jpg", quality=92, optimize=True)
315
316 ```
317
318 ### Step 3: Verify the result
319
320 ```python
321
322 # Save a crop of the same region after removal to confirm it's clean
323
324 verify = result_pil.crop((0, 0, min(w, 1000), min(h, 500)))
325
326 verify.save("debug_watermark_removed.jpg")
327
328 # View this image before proceeding to resize/crop
329
330 ```
331
332 #### Tips (2)
333
334 - For watermarks on uniform backgrounds (studio portraits, product photos), `INPAINT_TELEA`with`inpaintRadius=5-10` works well.
335 - For watermarks over textured areas (landscapes, fabric), use `INPAINT_NS` with a larger radius (10-15).
336
337 - If the watermark is semi-transparent, color-based masking (Option A) is more precise than a rectangular region mask.
338 - Always verify at full resolution — artifacts invisible in thumbnails may be obvious when zoomed in.
339
340 ## Text & Watermark (Adding)
341
342 ```python
343
344 from PIL import Image, ImageDraw, ImageFont
345
346 draw = ImageDraw.Draw(img)
347
348 try:
349
350 font = ImageFont.truetype("DejaVuSans-Bold.ttf", 48) \# Linux default
351
352 except OSError:
353
354 font = ImageFont.load_default() \# fallback (tiny, ugly)
355
356 # --- Text with outline ---
357
358 draw.text((50, 50), "Caption", font=font, fill="white",
359
360 stroke_width=3, stroke_fill="black")
361
362 # --- Centered text ---
363
364 bbox = draw.textbbox((0, 0), "Centered", font=font)
365
366 tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
367
368 draw.text(((img.width - tw) // 2, (img.height - th) // 2), "Centered", font=font, fill="white")
369
370 # --- Watermark (semi-transparent PNG overlay) ---
371
372 logo = Image.open("logo.png").convert("RGBA")
373
374 logo.thumbnail((img.width // 5, img.height // 5))
375
376 # Fade to 40% opacity
377
378 alpha = logo.split()[3].point(lambda p: int(p * 0.4))
379
380 logo.putalpha(alpha)
381
382 pos = (img.width - logo.width - 20, img.height - logo.height - 20)
383
384 img.paste(logo, pos, logo) \# third arg = alpha mask — REQUIRED for transparency
385
386 ```
387
388 ## Save & Optimize
389
390 ```python
391
392 # --- JPEG ---
393
394 img.convert("RGB").save("out.jpg", quality=85, optimize=True, progressive=True)
395
396 # convert("RGB") REQUIRED if source has alpha — JPEG can't store transparency
397
398 # --- PNG (lossless — quality param does nothing) ---
399
400 img.save("out.png", optimize=True, compress_level=9)
401
402 # --- WebP (best web format: ~30% smaller than JPEG at same quality) ---
403
404 img.save("out.webp", quality=85, method=6) \# method 0-6, 6=slowest/best compression
405
406 # --- AVIF (smallest files, Pillow 11+, slower encode) ---
407
408 img.save("out.avif", quality=75) \# 75 ≈ JPEG 85 visually, ~50% smaller
409
410 # --- Strip all metadata (privacy) ---
411
412 clean = Image.new(img.mode, img.size)
413
414 clean.putdata(list(img.getdata()))
415
416 clean.save("stripped.jpg", quality=85)
417
418 ```
419
420 **Quality guide:** JPEG/WebP 85 = sweet spot. 90+ = diminishing returns. <70 = visible artifacts. Never re-save JPEGs repeatedly — each save degrades (generation loss).
421
422 ## Batch Processing
423
424 ```python
425
426 from pathlib import Path
427
428 from PIL import Image, ImageOps
429
430 out = Path("optimized"); out.mkdir(exist_ok=True)
431
432 for p in Path("photos").glob("*.[jJ][pP]*[gG]"): \# matches jpg, jpeg, JPG, JPEG
433
434 img = ImageOps.exif_transpose(Image.open(p))
435
436 img.thumbnail((1920, 1920), Image.Resampling.LANCZOS)
437
438 img.convert("RGB").save(out / f"{p.stem}.webp", quality=85, method=6)
439
440 ```
441
442 ## sharp (Node.js — use for high throughput)
443
444 ```javascript
445
446 const sharp = require('sharp');
447
448 // Resize + convert + optimize, streaming (flat memory)
449
450 await sharp('in.jpg')
451
452 .rotate() // auto-rotate from EXIF (like exif_transpose)
453
454 .resize(1080, 1080, { fit: 'cover', position: 'center' }) // = ImageOps.fit
455
456 .webp({ quality: 85 })
457
458 .toFile('out.webp');
459
460 // fit options: 'cover' (crop), 'contain' (letterbox), 'inside' (shrink to fit), 'fill' (stretch)
461
462 // Composite watermark
463
464 await sharp('photo.jpg')
465
466 .composite([{ input: 'logo.png', gravity: 'southeast' }])
467
468 .toFile('watermarked.jpg');
469
470 ```
471
472 sharp strips all metadata by default. Use `.withMetadata()` to preserve EXIF/ICC.
473
474 ## OpenCV (when Pillow isn't enough)
475
476 ```python
477
478 import cv2
479
480 img = cv2.imread("in.jpg") \# BGR order, not RGB!
481
482 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
483
484 # Face detection
485
486 cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
487
488 faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5)
489
490 for (x, y, w, h) in faces:
491
492 cv2.rectangle(img, (x, y), (x+w, y+h), (0, 255, 0), 2)
493
494 cv2.imwrite("out.jpg", img)
495
496 # Pillow <-> OpenCV
497
498 import numpy as np
499
500 cv_img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
501
502 pil_img = Image.fromarray(cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB))
503
504 ```
505
506 ## Bulk Pixel Manipulation — Use numpy, Not getpixel/putpixel
507
508 **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.
509
510 ```python
511
512 import numpy as np
513
514 from PIL import Image
515
516 img = Image.open("photo.jpg").convert("RGB")
517
518 arr = np.array(img) \# shape: (height, width, 3), dtype: uint8
519
520 # Example: replace a region with sampled background color
521
522 bg_color = arr[50, -100, :] \# sample one pixel from the right side
523
524 arr[0:300, 0:800, :] = bg_color \# fill the region instantly
525
526 # Example: blend two regions with a gradient mask
527
528 alpha = np.linspace(1, 0, 100).reshape(1, 100, 1) \# horizontal fade over 100px
529
530 region = arr[0:300, 700:800, :]
531
532 bg_strip = np.full_like(region, bg_color)
533
534 arr[0:300, 700:800, :] = (region * (1 - alpha) + bg_strip * alpha).astype(np.uint8)
535
536 result = Image.fromarray(arr)
537
538 ```
539
540 **Speed comparison:** `putpixel` on a 700x250 region = ~175,000 calls = 30+ seconds. numpy array slice = instant.
541
542 ## Platform Dimensions
543
544 | Platform | Size | Ratio |
545
546 |---|---|---|
547
548 | Instagram post | 1080x1080 | 1:1 |
549
550 | Instagram story / TikTok | 1080x1920 | 9:16 |
551
552 | LinkedIn profile photo | 400x400 | 1:1 |
553
554 | LinkedIn banner | 1584x396 | 4:1 |
555
556 | LinkedIn post | 1200x627 | 1.91:1 |
557
558 | Twitter/X profile | 400x400 | 1:1 |
559
560 | Twitter/X post | 1200x675 | 16:9 |
561
562 | Facebook profile | 320x320 | 1:1 |
563
564 | Facebook cover | 851x315 | 2.7:1 |
565
566 | YouTube thumbnail | 1280x720 | 16:9 |
567
568 | WhatsApp profile | 500x500 | 1:1 |
569
570 | Open Graph (link preview) | 1200x630 | 1.91:1 |
571
572 ## Debug Workflow
573
574 When edits don't look right, follow this process instead of re-running the whole pipeline blindly:
575
576 1. **Save a debug crop of the target area** before and after processing. View both to confirm what changed.
577 2. **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.
578
579 3. **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.
580 4. **Spot-check specific pixels** to verify processing took effect:
581
582 ```python
583
584 # Quick pixel check after watermark removal
585
586 for (x, y) in [(60, 50), (200, 130), (400, 100)]:
587
588 print(f"({x},{y}): {img.getpixel((x, y))}")
589
590 # If these still show watermark colors (e.g. bright blue), removal failed
591
592 ```
593
594 ## Gotchas
595
596 - **`img.crop()`box is`(left, top, right, bottom)`** — absolute coords, NOT`(x, y, width, height)`
597 - **`thumbnail()`mutates in place and returns`None`** — don't do`img = img.thumbnail(...)`
598
599 - **Paste with transparency** needs the image as the third (mask) arg: `bg.paste(fg, pos, fg)`
600 - **Palette mode ("P")** breaks many filters — `img.convert("RGB")` first
601
602 - **Fonts:** `ImageFont.truetype`needs a real font file. Linux:`/usr/share/fonts/truetype/dejavu/`. Ship a`.ttf` with your code for portability.
603 - **OpenCV needs numpy** — always `pip install opencv-python numpy` together
604
605 - **OpenCV uses BGR, Pillow uses RGB** — convert when switching between them or colors will be wrong