Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save hanshuebner/5189b7585c2c280d56cb9d1974e4d2b3 to your computer and use it in GitHub Desktop.

Select an option

Save hanshuebner/5189b7585c2c280d56cb9d1974e4d2b3 to your computer and use it in GitHub Desktop.
Pencil Sketch Profile Picture Generator
"""
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