Skip to content

Instantly share code, notes, and snippets.

@pszemraj
Last active February 27, 2026 08:21
Show Gist options
  • Select an option

  • Save pszemraj/29935a481ced7ba19a5a60dcbe7db676 to your computer and use it in GitHub Desktop.

Select an option

Save pszemraj/29935a481ced7ba19a5a60dcbe7db676 to your computer and use it in GitHub Desktop.
create deep fried memes with python
#!/usr/bin/env python3
"""
Image distortion tool for creating "deep fried" meme-filters.
Dependencies:
pip install universal-pathlib Pillow numpy scipy matplotlib
Usage:
python deepfryer.py --help
# website-matching defaults
python deepfryer.py input.png -o output.jpg
# enhanced mode (recommended for pastel/light images)
python deepfryer.py input.png -o output.jpg --mode enhanced
Citation:
@misc{deepfriedmemes.com,
title = {deepfriedmemes.com},
author = {Narkevich, Dima},
year = {2017},
url = {https://github.com/efskap/deepfriedmemes.com},
note = {Accessed 2026-02-27}
}
"""
from __future__ import annotations
import argparse
import io
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from matplotlib.colors import hsv_to_rgb, rgb_to_hsv
from PIL import Image
from scipy.ndimage import convolve, map_coordinates
from upath import UPath
if TYPE_CHECKING:
from numpy.typing import NDArray
# ---------------------------------------------------------------------------
# Parameters
# ---------------------------------------------------------------------------
@dataclass
class FryParams:
"""
Parameters for deep frying.
Classic-mode filters use CamanJS-native units matching deepfriedmemes.com
slider ranges. Enhanced-mode params are independent.
"""
# Resize (0 = no resize)
max_dimension: int = 800
# Processing mode: "classic" (website replica) or "enhanced" (color-preserving)
mode: str = "classic"
# --- JPEG crushing (shared by both modes) ---
jpeg_quality: float = 0.175 # canvas toDataURL scale (0.0–0.5)
jpeg_iterations_before: int = 0 # website default: before=off
jpeg_iterations_after: int = 28 # website default: 28
# --- Classic-mode CamanJS filter units (website slider defaults) ---
brightness: float = 15.0 # -10 … 30
contrast: float = 155.0 # 0 … 300
sharpen: float = 83.0 # 0 … 800
saturation: float = 0.0 # 0 … 300
noise: float = 18.0 # 0 … 100
# --- Enhanced-mode params ---
sigmoid_k: float = 12.0 # contrast steepness (8=mild, 15=harsh)
sat_power: float = 0.3 # saturation boost curve (lower=more boost)
sat_scale: float = 1.5 # saturation multiplier after power curve
enhanced_brightness: float = 15.0 # additive brightness (same CamanJS units)
enhanced_sharpen: float = 83.0 # sharpen amount (CamanJS units)
enhanced_noise: float = 18.0 # noise amount (CamanJS units)
# --- Bulge distortion (glfx.js units, shared) ---
random_bulge_points: int = 0
bulge_strength: float = 0.7
bulge_radius_ratio: float = 0.2
# --- Output ---
output_quality: int = 80
# ---------------------------------------------------------------------------
# CamanJS filter implementations (per-pixel math from caman.full.min.js)
# ---------------------------------------------------------------------------
def _caman_brightness(arr: NDArray, adjust: float) -> NDArray:
"""Additive brightness. adjust in CamanJS slider units."""
arr[:, :, :3] += np.floor(255.0 * (adjust / 100.0))
return arr
def _caman_contrast(arr: NDArray, adjust: float) -> NDArray:
"""Power-curve midpoint stretch. adjust in CamanJS slider units (0–300)."""
if adjust == 0:
return arr
factor = ((adjust + 100.0) / 100.0) ** 2
rgb = arr[:, :, :3]
rgb /= 255.0
rgb -= 0.5
rgb *= factor
rgb += 0.5
rgb *= 255.0
return arr
def _caman_saturation(arr: NDArray, adjust: float) -> NDArray:
"""Push non-max channels toward max. adjust in CamanJS slider units."""
if adjust == 0:
return arr
adj = adjust * -0.01
rgb = arr[:, :, :3]
max_c = np.max(rgb, axis=2, keepdims=True)
rgb += (max_c - rgb) * adj
return arr
def _caman_sharpen(arr: NDArray, adjust: float) -> NDArray:
"""3x3 unsharp kernel. adjust in CamanJS slider units (0–800)."""
if adjust == 0:
return arr
amt = adjust / 100.0
kernel = np.array(
[[0.0, -amt, 0.0], [-amt, 4.0 * amt + 1.0, -amt], [0.0, -amt, 0.0]],
dtype=np.float64,
)
for c in range(3):
arr[:, :, c] = convolve(arr[:, :, c], kernel, mode="reflect")
return arr
def _caman_noise(arr: NDArray, adjust: float) -> NDArray:
"""Uniform random offset, same value for R/G/B per pixel."""
if adjust == 0:
return arr
effective = abs(adjust) * 2.55
h, w = arr.shape[:2]
rand = np.random.uniform(-effective, effective, (h, w))
arr[:, :, 0] += rand
arr[:, :, 1] += rand
arr[:, :, 2] += rand
return arr
def apply_classic_filters(img: Image.Image, p: FryParams) -> Image.Image:
"""CamanJS filters in the same order the website applies them."""
arr = np.array(img, dtype=np.float64)
arr = _caman_brightness(arr, p.brightness)
arr = _caman_contrast(arr, p.contrast)
arr = _caman_sharpen(arr, p.sharpen)
arr = _caman_saturation(arr, p.saturation)
arr = _caman_noise(arr, p.noise)
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
# ---------------------------------------------------------------------------
# Enhanced-mode filters (HSV-space, sigmoid contrast, color-preserving)
# ---------------------------------------------------------------------------
def apply_enhanced_filters(img: Image.Image, p: FryParams) -> Image.Image:
"""
Color-preserving deep fry via HSV processing.
Key differences from classic mode:
- Saturation is boosted via a power curve BEFORE contrast, so colors survive.
- Contrast is applied as a sigmoid on the V (value) channel only, giving
aggressive tonal crunch without hard-clipping color information to white.
- The result is vivid neon colors instead of washed-out pastels.
"""
arr = np.array(img, dtype=np.float64) / 255.0
hsv = rgb_to_hsv(arr[:, :, :3])
H = hsv[:, :, 0].copy()
S = hsv[:, :, 1].copy()
V = hsv[:, :, 2].copy()
# 1. Boost saturation with power curve: S' = (S ^ power) * scale
# power < 1 pushes low-saturation toward high (pastels become vivid).
S = np.power(np.clip(S, 1e-10, 1.0), p.sat_power)
S = np.clip(S * p.sat_scale, 0.0, 1.0)
# 2. Sigmoid contrast on V channel:
# Unlike CamanJS linear scaling, sigmoid soft-clips at the extremes so
# there is always tonal variation near white/black.
V = V + p.enhanced_brightness / 255.0
V = 1.0 / (1.0 + np.exp(-p.sigmoid_k * (V - 0.5)))
hsv[:, :, 0] = H
hsv[:, :, 1] = S
hsv[:, :, 2] = V
arr[:, :, :3] = hsv_to_rgb(hsv)
arr = arr * 255.0
# 3. Sharpen (same CamanJS kernel, proven effective)
arr = _caman_sharpen(arr, p.enhanced_sharpen)
# 4. Noise (same per-channel behavior)
arr = _caman_noise(arr, p.enhanced_noise)
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
# ---------------------------------------------------------------------------
# JPEG crushing (canvas toDataURL loop with edge-bleed trick)
# ---------------------------------------------------------------------------
def jpeg_crush(img: Image.Image, quality: float, iterations: int) -> Image.Image:
"""
Iteratively recompress as JPEG to accumulate artifacts.
Includes the edge-bleed row shift from deepfriedmemes.com's jpegize_inner:
ctx.drawImage(img, 0, 0, w, 2, 0, -1, w, 2)
ctx.drawImage(img, 0, h-2, w, 1, 0, h-1, w, 1)
"""
for _ in range(iterations):
arr = np.array(img)
arr[0] = arr[1]
arr[-1] = arr[-2]
img = Image.fromarray(arr)
q = max(1, min(95, int((quality + random.random() * 0.025) * 100)))
buf = io.BytesIO()
img.save(buf, "JPEG", quality=q)
buf.seek(0)
img = Image.open(buf).convert("RGB")
return img
# ---------------------------------------------------------------------------
# glfx.js bulgePinch (ported from the GLSL shader)
# ---------------------------------------------------------------------------
def _smoothstep(edge0: float, edge1: NDArray, x: NDArray) -> NDArray:
"""GLSL smoothstep: Hermite interpolation between 0 and 1."""
t = np.clip((x - edge0) / (edge1 - edge0 + 1e-10), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
def bulge_at(
arr: NDArray[np.float64],
cx: float,
cy: float,
radius: float,
strength: float,
) -> NDArray[np.float64]:
"""
glfx.js bulgePinch shader ported to numpy.
Positive strength = bulge outward, negative = pinch inward.
"""
h, w = arr.shape[:2]
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float64)
dx = x_coords - cx
dy = y_coords - cy
dist = np.sqrt(dx**2 + dy**2)
mask = (dist < radius) & (dist > 1e-10)
if not mask.any():
return arr
percent = np.zeros_like(dist)
percent[mask] = dist[mask] / radius
factor = np.ones_like(dist)
if strength > 0:
edge1 = np.zeros_like(dist)
edge1[mask] = radius / dist[mask]
ss = np.zeros_like(dist)
ss[mask] = _smoothstep(0.0, edge1[mask], percent[mask])
factor[mask] = 1.0 + (ss[mask] - 1.0) * strength * 0.75
else:
pwr = np.power(percent[mask], 1.0 + strength * 0.75)
ratio = pwr * radius / dist[mask]
blend = 1.0 - percent[mask]
factor[mask] = 1.0 + (ratio - 1.0) * blend
new_x = np.clip(cx + dx * factor, 0, w - 1)
new_y = np.clip(cy + dy * factor, 0, h - 1)
result = arr.copy()
for c in range(arr.shape[2]):
result[:, :, c] = map_coordinates(
arr[:, :, c],
[new_y, new_x],
order=1,
mode="reflect",
)
return result
def apply_bulges(
img: Image.Image,
points: list[tuple[float, float]],
radius: float,
strength: float,
) -> Image.Image:
"""Apply bulge distortion at given (x, y) points."""
arr = np.array(img, dtype=np.float64)
for cx, cy in points:
arr = bulge_at(arr, cx, cy, radius, strength)
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
# ---------------------------------------------------------------------------
# Main DeepFryer class
# ---------------------------------------------------------------------------
REMOTE_SCHEMES = ("http://", "https://", "s3://", "gs://")
def _open_image(path: str | UPath) -> Image.Image:
"""Open an image from a local path, URL, or any UPath-supported URI."""
path_str = str(path)
p = UPath(path)
# UPath gives HTTPPath / S3Path / etc. for remote URIs.
# PIL can't open those directly — read bytes into memory first.
if path_str.startswith(REMOTE_SCHEMES):
return Image.open(io.BytesIO(p.read_bytes()))
return Image.open(p)
class DeepFryer:
"""
Deep fries images.
Examples:
fryer = DeepFryer()
# Website-matching classic mode
fryer.fry("input.png", "output.jpg")
# Enhanced mode (better for pastel/light images)
fryer.fry("input.png", "output.jpg", mode="enhanced")
# Custom params
fryer.fry("input.png", "output.jpg", mode="enhanced", sigmoid_k=15)
"""
def __init__(self, **defaults):
self.defaults = defaults
def fry(
self,
input_path: str | UPath,
output_path: str | UPath | None = None,
*,
bulge_points: list[tuple[float, float]] | None = None,
**params,
) -> Image.Image:
"""
Deep fry an image.
Args:
input_path: Path to input image (local path, URL, or any UPath-supported URI).
output_path: Path for output. If None, returns image without saving.
bulge_points: Explicit (x, y) pixel coordinates for bulge centers.
**params: Override any FryParams field.
Returns:
The fried PIL Image.
"""
merged = {**self.defaults, **params}
p = FryParams(**{k: v for k, v in merged.items() if hasattr(FryParams, k)})
img = _open_image(UPath(input_path)).convert("RGB")
# 1. Resize
img = self._resize(img, p.max_dimension)
# 2. JPEG crush before filters
if p.jpeg_iterations_before > 0:
img = jpeg_crush(img, p.jpeg_quality, p.jpeg_iterations_before)
# 3. Bulge distortion (applied before filters, matching website order)
if bulge_points or p.random_bulge_points > 0:
w, h = img.size
radius = min(w, h) * p.bulge_radius_ratio
if bulge_points is None:
bulge_points = []
margin = int(radius)
for _ in range(p.random_bulge_points):
cx = random.randint(margin, max(margin, w - margin))
cy = random.randint(margin, max(margin, h - margin))
bulge_points.append((cx, cy))
img = apply_bulges(img, bulge_points, radius, p.bulge_strength)
# 4. Filters (mode-dependent)
if p.mode == "enhanced":
img = apply_enhanced_filters(img, p)
else:
img = apply_classic_filters(img, p)
# 5. JPEG crush after filters
if p.jpeg_iterations_after > 0:
img = jpeg_crush(img, p.jpeg_quality, p.jpeg_iterations_after)
# 6. Save
if output_path is not None:
output_path = UPath(output_path)
if not str(output_path).startswith(REMOTE_SCHEMES):
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(str(output_path), "JPEG", quality=p.output_quality)
return img
@staticmethod
def _resize(img: Image.Image, max_dim: int) -> Image.Image:
if max_dim <= 0:
return img
w, h = img.size
if max(w, h) <= max_dim:
return img
ratio = min(max_dim / w, max_dim / h)
return img.resize(
(int(w * ratio), int(h * ratio)),
Image.Resampling.LANCZOS,
)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def get_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Deep fry images from a local path or URL.",
epilog="Classic mode replicates deepfriedmemes.com; "
"enhanced mode preserves color identity with sigmoid contrast in HSV space.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("input", help="Input image path (file, URL, or any UPath URI)")
parser.add_argument(
"-o", "--output", default=None, help="Output path (default: <input>_fried.jpg)"
)
parser.add_argument(
"--mode",
choices=["classic", "enhanced"],
default="classic",
help="Processing mode",
)
g = parser.add_argument_group("resize")
g.add_argument("--max-dimension", type=int, default=800)
g = parser.add_argument_group("jpeg crushing")
g.add_argument(
"--jpeg-quality", type=float, default=0.175, help="JPEG quality (0.0–0.5)"
)
g.add_argument("--jpeg-iterations-before", type=int, default=0)
g.add_argument("--jpeg-iterations-after", type=int, default=28)
g = parser.add_argument_group("classic-mode CamanJS filters")
g.add_argument("--brightness", type=float, default=15.0)
g.add_argument("--contrast", type=float, default=155.0)
g.add_argument("--sharpen", type=float, default=83.0)
g.add_argument("--saturation", type=float, default=0.0)
g.add_argument("--noise", type=float, default=18.0)
g = parser.add_argument_group("enhanced-mode params")
g.add_argument(
"--sigmoid-k",
type=float,
default=12.0,
help="Contrast steepness (8=mild, 15=harsh)",
)
g.add_argument(
"--sat-power",
type=float,
default=0.3,
help="Saturation power curve (lower=more vivid)",
)
g.add_argument("--sat-scale", type=float, default=1.5, help="Saturation multiplier")
g.add_argument("--enhanced-brightness", type=float, default=15.0)
g.add_argument("--enhanced-sharpen", type=float, default=83.0)
g.add_argument("--enhanced-noise", type=float, default=18.0)
g = parser.add_argument_group("bulge distortion")
g.add_argument("-b", "--random-bulge-points", type=int, default=0)
g.add_argument("--bulge-strength", type=float, default=0.7)
g.add_argument("--bulge-radius-ratio", type=float, default=0.2)
g = parser.add_argument_group("output")
g.add_argument("-quality", "--output-quality", type=int, default=80)
return parser
def main():
"""main function"""
args = get_parser().parse_args()
input_path = UPath(args.input)
if args.output:
output_path = UPath(args.output)
else:
stem = input_path.stem
fname = f"{stem}_fried.jpg"
# save URLs, etc to ~/Downloads/
if str(input_path).startswith(REMOTE_SCHEMES):
output_path = UPath.home() / "Downloads" / fname
else:
output_path = input_path.parent / fname
fryer = DeepFryer()
fryer.fry(
input_path,
output_path,
mode=args.mode,
max_dimension=args.max_dimension,
jpeg_quality=args.jpeg_quality,
jpeg_iterations_before=args.jpeg_iterations_before,
jpeg_iterations_after=args.jpeg_iterations_after,
brightness=args.brightness,
contrast=args.contrast,
sharpen=args.sharpen,
saturation=args.saturation,
noise=args.noise,
sigmoid_k=args.sigmoid_k,
sat_power=args.sat_power,
sat_scale=args.sat_scale,
enhanced_brightness=args.enhanced_brightness,
enhanced_sharpen=args.enhanced_sharpen,
enhanced_noise=args.enhanced_noise,
random_bulge_points=args.random_bulge_points,
bulge_strength=args.bulge_strength,
bulge_radius_ratio=args.bulge_radius_ratio,
output_quality=args.output_quality,
)
print(f"Fried: {output_path}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment