|
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 |