Last active
February 27, 2026 08:21
-
-
Save pszemraj/29935a481ced7ba19a5a60dcbe7db676 to your computer and use it in GitHub Desktop.
create deep fried memes with python
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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