Created
March 2, 2026 08:53
-
-
Save hanshuebner/5189b7585c2c280d56cb9d1974e4d2b3 to your computer and use it in GitHub Desktop.
Pencil Sketch Profile Picture Generator
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
| """ | |
| Pencil Sketch Profile Picture Generator | |
| ======================================== | |
| A GUI tool for creating artistic pencil sketch portraits | |
| with full control over all image parameters. | |
| Requirements: pip install Pillow numpy | |
| Usage: python pencil_sketch_gui.py | |
| """ | |
| import tkinter as tk | |
| from tkinter import ttk, filedialog, colorchooser | |
| from PIL import Image, ImageFilter, ImageEnhance, ImageOps, ImageTk, ImageDraw | |
| import numpy as np | |
| import os | |
| class SketchApp: | |
| def __init__(self, root): | |
| self.root = root | |
| self.root.title("Pencil Sketch Generator") | |
| self.root.configure(bg="#2b2b2b") | |
| self.root.minsize(1100, 750) | |
| # State | |
| self.source_image = None | |
| self.source_path = None | |
| self.result_image = None | |
| self.preview_size = 400 | |
| # Default parameters | |
| self.defaults = { | |
| # Crop | |
| "crop_offset_x": 0.0, | |
| "crop_offset_y": -0.05, | |
| "crop_zoom": 1.0, | |
| # Enhancement | |
| "contrast": 1.6, | |
| "brightness": 1.05, | |
| "sharpness": 1.4, | |
| # Sketch | |
| "blur_radius": 12.0, | |
| "sketch_contrast": 1.5, | |
| # Colors | |
| "paper_r": 245, "paper_g": 235, "paper_b": 220, | |
| "ink_r": 45, "ink_g": 35, "ink_b": 30, | |
| # Output | |
| "output_size": 800, | |
| "circular_crop": False, | |
| } | |
| self.params = dict(self.defaults) | |
| self.vars = {} | |
| self._build_ui() | |
| # ── UI Construction ────────────────────────────────────────────── | |
| def _build_ui(self): | |
| style = ttk.Style() | |
| style.theme_use("clam") | |
| # Dark theme colors | |
| bg = "#2b2b2b" | |
| fg = "#e0e0e0" | |
| accent = "#d4a574" | |
| panel_bg = "#353535" | |
| slider_trough = "#444444" | |
| style.configure(".", background=bg, foreground=fg, font=("Helvetica", 10)) | |
| style.configure("TFrame", background=bg) | |
| style.configure("TLabelframe", background=panel_bg, foreground=accent, | |
| font=("Helvetica", 10, "bold")) | |
| style.configure("TLabelframe.Label", background=panel_bg, foreground=accent, | |
| font=("Helvetica", 10, "bold")) | |
| style.configure("TLabel", background=bg, foreground=fg) | |
| style.configure("TButton", background="#4a4a4a", foreground=fg, | |
| font=("Helvetica", 10), padding=6) | |
| style.map("TButton", | |
| background=[("active", accent)], | |
| foreground=[("active", "#1a1a1a")]) | |
| style.configure("Accent.TButton", background=accent, foreground="#1a1a1a", | |
| font=("Helvetica", 11, "bold"), padding=8) | |
| style.map("Accent.TButton", | |
| background=[("active", "#e8bc94")]) | |
| style.configure("TCheckbutton", background=bg, foreground=fg) | |
| style.configure("TScale", background=bg, troughcolor=slider_trough) | |
| style.configure("Header.TLabel", font=("Helvetica", 14, "bold"), | |
| foreground=accent, background=bg) | |
| # Main layout: left controls, right preview | |
| main = ttk.Frame(self.root) | |
| main.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) | |
| # Left panel (scrollable controls) | |
| left_frame = ttk.Frame(main) | |
| left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) | |
| # Canvas + scrollbar for controls | |
| ctrl_canvas = tk.Canvas(left_frame, bg=bg, highlightthickness=0, width=320) | |
| ctrl_scrollbar = ttk.Scrollbar(left_frame, orient=tk.VERTICAL, | |
| command=ctrl_canvas.yview) | |
| self.controls_frame = ttk.Frame(ctrl_canvas) | |
| self.controls_frame.bind( | |
| "<Configure>", | |
| lambda e: ctrl_canvas.configure(scrollregion=ctrl_canvas.bbox("all")) | |
| ) | |
| ctrl_canvas.create_window((0, 0), window=self.controls_frame, anchor="nw") | |
| ctrl_canvas.configure(yscrollcommand=ctrl_scrollbar.set) | |
| ctrl_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) | |
| ctrl_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) | |
| # Mouse wheel scrolling | |
| def _on_mousewheel(event): | |
| ctrl_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") | |
| ctrl_canvas.bind_all("<MouseWheel>", _on_mousewheel) | |
| ctrl_canvas.bind_all("<Button-4>", lambda e: ctrl_canvas.yview_scroll(-1, "units")) | |
| ctrl_canvas.bind_all("<Button-5>", lambda e: ctrl_canvas.yview_scroll(1, "units")) | |
| cf = self.controls_frame | |
| # Title | |
| ttk.Label(cf, text="Pencil Sketch", style="Header.TLabel").pack( | |
| pady=(0, 10), anchor="w") | |
| # Load / Save buttons | |
| btn_frame = ttk.Frame(cf) | |
| btn_frame.pack(fill=tk.X, pady=(0, 10)) | |
| ttk.Button(btn_frame, text="Load Image", style="Accent.TButton", | |
| command=self._load_image).pack(side=tk.LEFT, padx=(0, 5)) | |
| ttk.Button(btn_frame, text="Save Result", | |
| command=self._save_image).pack(side=tk.LEFT, padx=(0, 5)) | |
| ttk.Button(btn_frame, text="Reset", | |
| command=self._reset_params).pack(side=tk.LEFT) | |
| # ── Crop Section ── | |
| crop_frame = ttk.LabelFrame(cf, text=" Crop ", padding=10) | |
| crop_frame.pack(fill=tk.X, pady=5) | |
| self._add_slider(crop_frame, "crop_offset_x", "Horizontal Offset", | |
| -0.5, 0.5, 0.01) | |
| self._add_slider(crop_frame, "crop_offset_y", "Vertical Offset", | |
| -0.5, 0.5, 0.01) | |
| self._add_slider(crop_frame, "crop_zoom", "Zoom", | |
| 0.5, 2.0, 0.05) | |
| # ── Enhancement Section ── | |
| enh_frame = ttk.LabelFrame(cf, text=" Enhancement ", padding=10) | |
| enh_frame.pack(fill=tk.X, pady=5) | |
| self._add_slider(enh_frame, "contrast", "Contrast", | |
| 0.5, 3.0, 0.05) | |
| self._add_slider(enh_frame, "brightness", "Brightness", | |
| 0.5, 2.0, 0.05) | |
| self._add_slider(enh_frame, "sharpness", "Sharpness", | |
| 0.0, 3.0, 0.05) | |
| # ── Sketch Section ── | |
| sk_frame = ttk.LabelFrame(cf, text=" Sketch ", padding=10) | |
| sk_frame.pack(fill=tk.X, pady=5) | |
| self._add_slider(sk_frame, "blur_radius", "Blur Radius", | |
| 1.0, 40.0, 0.5) | |
| self._add_slider(sk_frame, "sketch_contrast", "Sketch Contrast", | |
| 0.5, 3.0, 0.05) | |
| # ── Colors Section ── | |
| col_frame = ttk.LabelFrame(cf, text=" Colors ", padding=10) | |
| col_frame.pack(fill=tk.X, pady=5) | |
| paper_row = ttk.Frame(col_frame) | |
| paper_row.pack(fill=tk.X, pady=3) | |
| ttk.Label(paper_row, text="Paper Color").pack(side=tk.LEFT) | |
| self.paper_swatch = tk.Canvas(paper_row, width=40, height=22, | |
| bg=self._rgb_hex("paper"), highlightthickness=1, | |
| highlightbackground="#666") | |
| self.paper_swatch.pack(side=tk.RIGHT, padx=5) | |
| self.paper_swatch.bind("<Button-1>", lambda e: self._pick_color("paper")) | |
| ink_row = ttk.Frame(col_frame) | |
| ink_row.pack(fill=tk.X, pady=3) | |
| ttk.Label(ink_row, text="Ink Color").pack(side=tk.LEFT) | |
| self.ink_swatch = tk.Canvas(ink_row, width=40, height=22, | |
| bg=self._rgb_hex("ink"), highlightthickness=1, | |
| highlightbackground="#666") | |
| self.ink_swatch.pack(side=tk.RIGHT, padx=5) | |
| self.ink_swatch.bind("<Button-1>", lambda e: self._pick_color("ink")) | |
| # Presets | |
| preset_label = ttk.Label(col_frame, text="Presets:") | |
| preset_label.pack(anchor="w", pady=(8, 3)) | |
| presets_row = ttk.Frame(col_frame) | |
| presets_row.pack(fill=tk.X) | |
| presets = [ | |
| ("Warm Cream", (245, 235, 220), (45, 35, 30)), | |
| ("Cool White", (240, 242, 248), (30, 35, 50)), | |
| ("Kraft Paper", (210, 190, 160), (50, 35, 20)), | |
| ("Blueprint", (35, 55, 95), (200, 215, 240)), | |
| ("Dark Mode", (30, 30, 35), (210, 200, 185)), | |
| ] | |
| for name, paper, ink in presets: | |
| btn = ttk.Button(presets_row, text=name, | |
| command=lambda p=paper, i=ink: self._apply_preset(p, i)) | |
| btn.pack(fill=tk.X, pady=1) | |
| # ── Output Section ── | |
| out_frame = ttk.LabelFrame(cf, text=" Output ", padding=10) | |
| out_frame.pack(fill=tk.X, pady=5) | |
| self._add_slider(out_frame, "output_size", "Size (px)", | |
| 256, 2048, 64) | |
| self.circular_var = tk.BooleanVar(value=False) | |
| ttk.Checkbutton(out_frame, text="Circular crop", | |
| variable=self.circular_var, | |
| command=self._on_param_change).pack(anchor="w", pady=3) | |
| # Right panel (preview) | |
| right_frame = ttk.Frame(main) | |
| right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) | |
| self.canvas = tk.Canvas(right_frame, bg="#1e1e1e", highlightthickness=0) | |
| self.canvas.pack(fill=tk.BOTH, expand=True) | |
| self.status_var = tk.StringVar(value="Load an image to get started") | |
| ttk.Label(right_frame, textvariable=self.status_var, | |
| foreground="#888").pack(pady=(5, 0)) | |
| def _add_slider(self, parent, key, label, from_, to_, resolution): | |
| frame = ttk.Frame(parent) | |
| frame.pack(fill=tk.X, pady=2) | |
| ttk.Label(frame, text=label, width=18).pack(side=tk.LEFT) | |
| var = tk.DoubleVar(value=self.params[key]) | |
| self.vars[key] = var | |
| value_label = ttk.Label(frame, text=f"{self.params[key]:.2f}", width=6) | |
| value_label.pack(side=tk.RIGHT) | |
| slider = ttk.Scale(frame, from_=from_, to=to_, variable=var, | |
| orient=tk.HORIZONTAL, | |
| command=lambda v, k=key, vl=value_label: self._slider_changed(k, v, vl)) | |
| slider.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=5) | |
| def _slider_changed(self, key, value, value_label): | |
| val = float(value) | |
| self.params[key] = val | |
| value_label.configure(text=f"{val:.2f}") | |
| self._on_param_change() | |
| # ── Color Helpers ──────────────────────────────────────────────── | |
| def _rgb_hex(self, prefix): | |
| r = self.params[f"{prefix}_r"] | |
| g = self.params[f"{prefix}_g"] | |
| b = self.params[f"{prefix}_b"] | |
| return f"#{int(r):02x}{int(g):02x}{int(b):02x}" | |
| def _pick_color(self, prefix): | |
| current = (self.params[f"{prefix}_r"], | |
| self.params[f"{prefix}_g"], | |
| self.params[f"{prefix}_b"]) | |
| result = colorchooser.askcolor(color=current, | |
| title=f"Choose {prefix} color") | |
| if result and result[0]: | |
| r, g, b = [int(c) for c in result[0]] | |
| self.params[f"{prefix}_r"] = r | |
| self.params[f"{prefix}_g"] = g | |
| self.params[f"{prefix}_b"] = b | |
| swatch = self.paper_swatch if prefix == "paper" else self.ink_swatch | |
| swatch.configure(bg=f"#{r:02x}{g:02x}{b:02x}") | |
| self._on_param_change() | |
| def _apply_preset(self, paper, ink): | |
| self.params["paper_r"], self.params["paper_g"], self.params["paper_b"] = paper | |
| self.params["ink_r"], self.params["ink_g"], self.params["ink_b"] = ink | |
| self.paper_swatch.configure(bg=self._rgb_hex("paper")) | |
| self.ink_swatch.configure(bg=self._rgb_hex("ink")) | |
| self._on_param_change() | |
| # ── Image Processing ───────────────────────────────────────────── | |
| def _crop_image(self, img): | |
| w, h = img.size | |
| zoom = self.params["crop_zoom"] | |
| crop_size = min(w, h) / zoom | |
| cx = w / 2 + self.params["crop_offset_x"] * w | |
| cy = h / 2 + self.params["crop_offset_y"] * h | |
| left = max(0, cx - crop_size / 2) | |
| top = max(0, cy - crop_size / 2) | |
| right = min(w, left + crop_size) | |
| bottom = min(h, top + crop_size) | |
| # Clamp | |
| if right - left < crop_size: | |
| left = max(0, right - crop_size) | |
| if bottom - top < crop_size: | |
| top = max(0, bottom - crop_size) | |
| return img.crop((int(left), int(top), int(right), int(bottom))) | |
| def _process(self, img): | |
| p = self.params | |
| size = int(p["output_size"]) | |
| # Crop | |
| img = self._crop_image(img) | |
| img = img.resize((size, size), Image.LANCZOS) | |
| # Enhance | |
| img = ImageEnhance.Contrast(img).enhance(p["contrast"]) | |
| img = ImageEnhance.Brightness(img).enhance(p["brightness"]) | |
| img = ImageEnhance.Sharpness(img).enhance(p["sharpness"]) | |
| # Sketch: invert → blur → color dodge | |
| gray = img.convert('L') | |
| inv = ImageOps.invert(gray) | |
| blurred = inv.filter(ImageFilter.GaussianBlur(radius=p["blur_radius"])) | |
| gray_arr = np.array(gray, dtype=np.float32) | |
| blur_arr = np.array(blurred, dtype=np.float32) | |
| dodge = np.where( | |
| blur_arr != 255, | |
| np.clip(gray_arr * 256.0 / (256.0 - blur_arr), 0, 255), | |
| 255 | |
| ) | |
| sketch = Image.fromarray(dodge.astype(np.uint8), 'L') | |
| sketch = ImageEnhance.Contrast(sketch).enhance(p["sketch_contrast"]) | |
| # Tint | |
| paper = (p["paper_r"], p["paper_g"], p["paper_b"]) | |
| ink = (p["ink_r"], p["ink_g"], p["ink_b"]) | |
| sketch_arr = np.array(sketch, dtype=np.float32) / 255.0 | |
| result = np.zeros((size, size, 3), dtype=np.float32) | |
| for c in range(3): | |
| result[:, :, c] = ink[c] * (1 - sketch_arr) + paper[c] * sketch_arr | |
| result_img = Image.fromarray(np.clip(result, 0, 255).astype(np.uint8), 'RGB') | |
| # Circular crop | |
| if self.circular_var.get(): | |
| mask = Image.new('L', (size, size), 0) | |
| draw = ImageDraw.Draw(mask) | |
| margin = int(size * 0.025) | |
| draw.ellipse((margin, margin, size - margin, size - margin), fill=255) | |
| mask = mask.filter(ImageFilter.GaussianBlur(3)) | |
| bg = Image.new('RGB', (size, size), | |
| (int(paper[0]), int(paper[1]), int(paper[2]))) | |
| bg.paste(result_img, (0, 0), mask) | |
| result_img = bg | |
| return result_img | |
| # ── Actions ────────────────────────────────────────────────────── | |
| def _load_image(self): | |
| path = filedialog.askopenfilename( | |
| filetypes=[("Images", "*.jpg *.jpeg *.png *.bmp *.tiff *.webp"), | |
| ("All files", "*.*")]) | |
| if not path: | |
| return | |
| self.source_path = path | |
| self.source_image = Image.open(path) | |
| self.status_var.set(f"Loaded: {os.path.basename(path)}") | |
| self._on_param_change() | |
| def _save_image(self): | |
| if self.result_image is None: | |
| return | |
| path = filedialog.asksaveasfilename( | |
| defaultextension=".png", | |
| filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg")]) | |
| if path: | |
| self.result_image.save(path, quality=95) | |
| self.status_var.set(f"Saved: {os.path.basename(path)}") | |
| def _reset_params(self): | |
| self.params = dict(self.defaults) | |
| for key, var in self.vars.items(): | |
| var.set(self.params[key]) | |
| self.circular_var.set(False) | |
| self.paper_swatch.configure(bg=self._rgb_hex("paper")) | |
| self.ink_swatch.configure(bg=self._rgb_hex("ink")) | |
| self._on_param_change() | |
| def _on_param_change(self, *_): | |
| # Sync slider vars → params | |
| for key, var in self.vars.items(): | |
| self.params[key] = var.get() | |
| self.params["circular_crop"] = self.circular_var.get() | |
| if self.source_image is None: | |
| return | |
| # Process at preview resolution for speed | |
| preview_params = dict(self.params) | |
| preview_params["output_size"] = self.preview_size | |
| saved_size = self.params["output_size"] | |
| self.params["output_size"] = self.preview_size | |
| result = self._process(self.source_image) | |
| self.params["output_size"] = saved_size | |
| self.result_image = None # Will regenerate at full res on save | |
| # Store for save (regenerate at full res) | |
| self._last_result_preview = result | |
| # Display | |
| self._show_preview(result) | |
| def _save_image(self): | |
| if self.source_image is None: | |
| return | |
| path = filedialog.asksaveasfilename( | |
| defaultextension=".png", | |
| filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg")]) | |
| if not path: | |
| return | |
| # Render at full resolution | |
| self.status_var.set("Rendering at full resolution...") | |
| self.root.update() | |
| self.result_image = self._process(self.source_image) | |
| self.result_image.save(path, quality=95) | |
| self.status_var.set(f"Saved: {os.path.basename(path)} " | |
| f"({int(self.params['output_size'])}×{int(self.params['output_size'])}px)") | |
| def _show_preview(self, img): | |
| # Fit to canvas | |
| cw = self.canvas.winfo_width() or 500 | |
| ch = self.canvas.winfo_height() or 500 | |
| iw, ih = img.size | |
| scale = min(cw / iw, ch / ih, 1.0) | |
| display_size = (int(iw * scale), int(ih * scale)) | |
| display_img = img.resize(display_size, Image.LANCZOS) | |
| self._tk_image = ImageTk.PhotoImage(display_img) | |
| self.canvas.delete("all") | |
| x = cw // 2 | |
| y = ch // 2 | |
| self.canvas.create_image(x, y, image=self._tk_image, anchor=tk.CENTER) | |
| def main(): | |
| root = tk.Tk() | |
| app = SketchApp(root) | |
| root.mainloop() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment