2025-07-06 Web Development
How to Create a Polygonal Scissor Cutout Effect with Python
By O. Wolfson
Overview
This guide shows how to turn a photo (like cat.jpeg
) into a “scissor-cut” collage-style PNG with a rough polygonal edge and full transparency—all self-hosted, with Python scripts.
You’ll go from original photo to a hand-cut looking PNG (cat_final.png
) using open source tools and a few simple scripts.


1. Install Dependencies
First, make a Python 3 virtual environment (optional but recommended):
shpython3 -m venv .venv
source .venv/bin/activate
Then install all required packages:
shpip install 'rembg[cli]' pillow numpy opencv-python
Note: It may be necessary to install more dependencies to get rembg to work. Read the snippet below for more details.
shpip install -r requirements.txt
2. Remove the Background with rembg
Use rembg to make your cutout:
shrembg i cat.jpeg cat_cutout.png
- Input: your original photo (
cat.jpeg
) - Output:
cat_cutout.png
— subject only, transparent background, soft edges
3. Make a Hard-Edge PNG
Convert soft alpha to a perfectly “hard” mask with this script (hard_edge.py
):
python# hard_edge.py
from PIL import Image
import numpy as np
def hard_edge(input_png, output_png, threshold=128):
img = Image.open(input_png).convert("RGBA")
arr = np.array(img)
alpha = arr[:, :, 3]
arr[:, :, 3] = np.where(alpha > threshold, 255, 0)
Image.fromarray(arr).save(output_png)
print(f"Saved hard-edged PNG to {output_png}")
if __name__ == "__main__":
import sys
if len(sys.argv) < 3:
print("Usage: python hard_edge.py input.png output.png [threshold]")
else:
input_png = sys.argv[1]
output_png = sys.argv[2]
threshold = int(sys.argv[3]) if len(sys.argv) > 3 else 128
hard_edge(input_png, output_png, threshold)
Run:
shpython hard_edge.py cat_cutout.png cat_hardedge.png
Now you have a crisp, binary edge PNG: cat_hardedge.png
4. Create the Polygonal Scissor Mask
Use this script (improved_polygon_scissor.py
) for the "fast cut" polygonal look:
python"""
improved_polygon_scissor.py
Generates a polygonal, rough-edged ("scissor-cut") mask for a PNG with transparency.
Intended Use:
-------------
- For images with a hard, opaque subject (alpha=255) and transparent background.
- Simulates a fast, imprecise scissor cut: outline becomes a straight-edged polygon, optionally wobbly.
Parameters:
-----------
- input_png: Input PNG (e.g. from rembg + hard_edge.py).
- output_png: Output PNG.
- poly_points: Polygon vertices (lower = rougher, higher = smoother).
- offset: Distance to push cut outwards (pixels).
- randomness: Max random jitter per corner (pixels).
Usage Example:
--------------
python improved_polygon_scissor.py cat_hardedge.png cat_scissorpoly.png 18 20 7
Dependencies: Python 3.x, numpy, Pillow, opencv-python
"""
from PIL import Image, ImageDraw
import numpy as np
import cv2
import sys
def interpolate_contour(contour, n_points):
contour = contour.squeeze()
if contour.shape[0] < 3:
return contour
dists = np.sqrt(np.sum(np.diff(contour, axis=0)**2, axis=1))
dists = np.insert(dists, 0, 0)
total_dist = np.sum(dists)
steps = np.linspace(0, total_dist, n_points, endpoint=False)
interp_points = []
idx = 0
acc_dist = 0
for s in steps:
while idx < len(dists)-1 and acc_dist + dists[idx+1] < s:
acc_dist += dists[idx+1]
idx += 1
if idx >= len(contour)-1:
interp_points.append(contour[-1])
else:
frac = (s - acc_dist) / (dists[idx+1] if dists[idx+1] else 1)
interp = contour[idx] * (1-frac) + contour[idx+1] * frac
interp_points.append(interp.astype(int))
return np.array(interp_points)
def improved_polygonal_scissor(input_png, output_png, poly_points=18, offset=8, randomness=7):
img = Image.open(input_png).convert("RGBA")
arr = np.array(img)
alpha = arr[:, :, 3]
contours, _ = cv2.findContours((alpha > 0).astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
print("No subject found.")
return
contour = max(contours, key=cv2.contourArea)
poly = interpolate_contour(contour, poly_points)
h, w = alpha.shape
cx, cy = w//2, h//2
poly_jittered = []
for (x, y) in poly:
vec_x = x - cx
vec_y = y - cy
norm = np.sqrt(vec_x**2 + vec_y**2) or 1
ox = int(vec_x / norm * offset)
oy = int(vec_y / norm * offset)
dx = np.random.randint(-randomness, randomness+1)
dy = np.random.randint(-randomness, randomness+1)
nx = int(np.clip(x + dx + ox, 0, w-1))
ny = int(np.clip(y + dy + oy, 0, h-1))
poly_jittered.append((nx, ny))
new_alpha = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(new_alpha)
if len(poly_jittered) > 2:
draw.polygon(poly_jittered, fill=255)
out_arr = arr.copy()
out_arr[:, :, 3] = np.array(new_alpha)
Image.fromarray(out_arr).save(output_png)
print(f"Saved improved polygonal scissor cut to {output_png}")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python improved_polygon_scissor.py input.png output.png [poly_points] [offset] [randomness]")
else:
input_png = sys.argv[1]
output_png = sys.argv[2]
poly_points = int(sys.argv[3]) if len(sys.argv) > 3 else 18
offset = int(sys.argv[4]) if len(sys.argv) > 4 else 8
randomness = int(sys.argv[5]) if len(sys.argv) > 5 else 7
improved_polygonal_scissor(input_png, output_png, poly_points, offset, randomness)
Run:
shpython improved_polygon_scissor.py cat_hardedge.png cat_scissorpoly.png 18 20 7
Now you have a PNG (cat_scissorpoly.png
) with a rough polygonal alpha mask!
5. Apply the Polygonal Alpha to the Original Image
Use this script (apply_alpha_from_mask.py
) to combine the original photo with your new alpha mask:
python"""
apply_alpha_from_mask.py
Applies the alpha channel from a mask PNG to an original JPG/PNG.
Usage:
python apply_alpha_from_mask.py original_image.jpg mask_with_alpha.png output.png
"""
from PIL import Image
import sys
import numpy as np
def apply_alpha(original_path, alpha_path, output_path):
orig = Image.open(original_path).convert("RGBA")
mask = Image.open(alpha_path).convert("RGBA")
orig_np = np.array(orig)
mask_np = np.array(mask)
orig_np[:, :, 3] = mask_np[:, :, 3]
Image.fromarray(orig_np).save(output_path)
print(f"Saved: {output_path}")
if __name__ == "__main__":
if len(sys.argv) < 4:
print("Usage: python apply_alpha_from_mask.py original.jpg mask.png output.png")
sys.exit(1)
apply_alpha(sys.argv[1], sys.argv[2], sys.argv[3])
Run:
shpython apply_alpha_from_mask.py cat.jpeg cat_scissorpoly.png cat_final.png
🎉 Final Result
cat_final.png
: The original photo, but with a polygonal, scissor-cut, transparent edge!
Tips and Customization
- Tweak polygon points, offset, and randomness for different scissor styles.
- You can batch these steps in a shell script for many images.
- All scripts work on Mac, Windows, or Linux (Python 3.x required).
Requirements Recap
Install these once in your virtualenv:
shpip install 'rembg[cli]' pillow numpy opencv-python
File Flow Example
cat.jpeg → rembg → cat_cutout.png → hard_edge.py → cat_hardedge.png → improved_polygon_scissor.py → cat_scissorpoly.png → apply_alpha_from_mask.py → cat_final.png
For any issues, check your terminal for error messages. All scripts can be chained or combined as needed!
Here’s a single Python script that performs all steps in-memory (no intermediate files), starting from your original image (e.g. cat.jpeg
) and producing your final scissor-cut PNG (cat_final.png
).
You can run this from the command line and adjust polygon points, offset, and randomness as arguments.
python"""
scissor_pipeline.py
End-to-end: Remove background (rembg), harden edge, polygonal scissor-cut, and composite with original.
Requires: rembg, pillow, numpy, opencv-python
Usage:
python scissor_pipeline.py cat.jpeg cat_final.png [poly_points] [offset] [randomness]
"""
import sys
import numpy as np
from PIL import Image, ImageDraw
import cv2
# 1. Background removal (requires rembg as Python library)
def rembg_cutout(input_path):
from rembg import remove
with open(input_path, 'rb') as f:
input_bytes = f.read()
result = remove(input_bytes)
from io import BytesIO
return Image.open(BytesIO(result)).convert("RGBA")
# 2. Hard-edge threshold
def hard_edge(img, threshold=128):
arr = np.array(img)
alpha = arr[:, :, 3]
arr[:, :, 3] = np.where(alpha > threshold, 255, 0)
return Image.fromarray(arr)
# 3. Polygonal scissor mask
def interpolate_contour(contour, n_points):
contour = contour.squeeze()
if contour.shape[0] < 3:
return contour
dists = np.sqrt(np.sum(np.diff(contour, axis=0)**2, axis=1))
dists = np.insert(dists, 0, 0)
total_dist = np.sum(dists)
steps = np.linspace(0, total_dist, n_points, endpoint=False)
interp_points = []
idx = 0
acc_dist = 0
for s in steps:
while idx < len(dists)-1 and acc_dist + dists[idx+1] < s:
acc_dist += dists[idx+1]
idx += 1
if idx >= len(contour)-1:
interp_points.append(contour[-1])
else:
frac = (s - acc_dist) / (dists[idx+1] if dists[idx+1] else 1)
interp = contour[idx] * (1-frac) + contour[idx+1] * frac
interp_points.append(interp.astype(int))
return np.array(interp_points)
def polygonal_scissor(img, poly_points=18, offset=8, randomness=7):
arr = np.array(img)
alpha = arr[:, :, 3]
contours, _ = cv2.findContours((alpha > 0).astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
raise ValueError("No subject found.")
contour = max(contours, key=cv2.contourArea)
poly = interpolate_contour(contour, poly_points)
h, w = alpha.shape
cx, cy = w//2, h//2
poly_jittered = []
for (x, y) in poly:
vec_x = x - cx
vec_y = y - cy
norm = np.sqrt(vec_x**2 + vec_y**2) or 1
ox = int(vec_x / norm * offset)
oy = int(vec_y / norm * offset)
dx = np.random.randint(-randomness, randomness+1)
dy = np.random.randint(-randomness, randomness+1)
nx = int(np.clip(x + dx + ox, 0, w-1))
ny = int(np.clip(y + dy + oy, 0, h-1))
poly_jittered.append((nx, ny))
new_alpha = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(new_alpha)
if len(poly_jittered) > 2:
draw.polygon(poly_jittered, fill=255)
arr[:, :, 3] = np.array(new_alpha)
return Image.fromarray(arr)
# 4. Composite final image
def apply_alpha(orig_path, mask_img, output_path):
orig = Image.open(orig_path).convert("RGBA")
mask_np = np.array(mask_img)
orig_np = np.array(orig)
orig_np[:, :, 3] = mask_np[:, :, 3]
Image.fromarray(orig_np).save(output_path)
print(f"Saved: {output_path}")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python scissor_pipeline.py original.jpg final.png [poly_points] [offset] [randomness]")
sys.exit(1)
orig_path = sys.argv[1]
output_path = sys.argv[2]
poly_points = int(sys.argv[3]) if len(sys.argv) > 3 else 18
offset = int(sys.argv[4]) if len(sys.argv) > 4 else 20
randomness = int(sys.argv[5]) if len(sys.argv) > 5 else 7
print("Running rembg...")
cutout = rembg_cutout(orig_path)
print("Applying hard edge...")
hard = hard_edge(cutout)
print("Applying polygonal scissor mask...")
scissor = polygonal_scissor(hard, poly_points, offset, randomness)
print("Compositing final image...")
apply_alpha(orig_path, scissor, output_path)
print("🎉 All done! Check", output_path)
How to use:
shpython scissor_pipeline.py cat.jpeg cat_final.png 18 20 7
cat.jpeg
: your original photocat_final.png
: the PNG output with a polygonal, scissor-style transparent edge
Optional last three arguments:
- Polygon points (default 18)
- Offset (default 20)
- Randomness (default 7)
Requirements
shpip install rembg pillow numpy opencv-python