OWolf

BlogToolsProjectsAboutContact
© 2025 owolf.com
HomeAboutNotesContactPrivacy

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.

Original Cat (Square)Scissor Cutout Cat (Square)

1. Install Dependencies

First, make a Python 3 virtual environment (optional but recommended):

sh
python3 -m venv .venv
source .venv/bin/activate

Then install all required packages:

sh
pip 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.

Download requirements.txt

sh
pip install -r requirements.txt

2. Remove the Background with rembg

Use rembg to make your cutout:

sh
rembg 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:

sh
python 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:

sh
python 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:

sh
python 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:

sh
pip 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:

sh
python scissor_pipeline.py cat.jpeg cat_final.png 18 20 7
  • cat.jpeg: your original photo
  • cat_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

sh
pip install rembg pillow numpy opencv-python