← back to Filibuster Marathon Cartoon
generate_conrad_cartoon.py
244 lines
#!/usr/bin/env python3
"""
Generate 'The Filibuster Marathon' in Paul Conrad's actual ink style.
- Heavy brush strokes (thick lines, weight variation)
- Crosshatching for texture and shadow
- High contrast (black on cream)
- Hand-drawn, organic quality
- Single panel, editorial cartoon
"""
from PIL import Image, ImageDraw, ImageFont, ImageChops, ImageFilter
import math
import random
# Constants
WIDTH, HEIGHT = 1200, 920
CREAM = (205, 245, 224) # #cdf5e0
INK = (11, 11, 12) # #0b0b0c
MINT = (15, 157, 120) # #0f9d78
# Create base image
img = Image.new('RGB', (WIDTH, HEIGHT), CREAM)
draw = ImageDraw.Draw(img, 'RGBA')
def draw_thick_line(draw, x1, y1, x2, y2, width=6, ink=INK):
"""Draw a thick line with slight taper."""
draw.line([(x1, y1), (x2, y2)], fill=ink, width=width)
def draw_crosshatch(draw, x, y, w, h, spacing=8, ink=INK, alpha=200, angle=45):
"""Fill a rectangle region with crosshatching."""
# First diagonal
for i in range(-h, w, spacing):
x1, y1 = x + i, y
x2, y2 = x + i + h, y + h
draw.line([(x1, y1), (x2, y2)], fill=(*ink, alpha), width=2)
# Second diagonal (perpendicular)
for i in range(-w, h, spacing):
x1, y1 = x, y + i
x2, y2 = x + w, y + i + w
draw.line([(x1, y1), (x2, y2)], fill=(*ink, alpha), width=2)
def draw_dark_chamber(draw):
"""Dark chamber background with heavy fill and crosshatching."""
# Solid dark fill
draw.rectangle([(30, 30), (WIDTH-30, HEIGHT//2)], fill=(20, 20, 25))
# Crosshatch for texture
draw_crosshatch(draw, 30, 30, WIDTH-60, HEIGHT//2-30, spacing=12, alpha=180)
def draw_slouched_figure(draw, cx, cy, small=False):
"""Draw a single slouched Congress member silhouette."""
scale = 0.7 if small else 1.0
# Head (circle)
head_r = int(14 * scale)
draw.ellipse([(cx-head_r, cy-head_r-20), (cx+head_r, cy-head_r+20)], fill=INK)
# Body (slouched)
body_h = int(30 * scale)
draw.ellipse([(cx-int(24*scale), cy), (cx+int(24*scale), cy+body_h)], fill=INK)
def draw_chamber_of_congress(draw):
"""Draw the dark, drowsy chamber with many slouched members."""
positions = [
(120, 280), (220, 300), (340, 270), (480, 310),
(740, 300), (850, 270), (980, 320), (1050, 280),
(200, 210), (450, 190), (800, 220), (950, 200),
]
for cx, cy in positions:
draw_slouched_figure(draw, cx, cy, small=random.random() < 0.3)
def draw_melted_clock(draw):
"""Draw the Dali-melted clock as the visual anchor."""
cx, cy = WIDTH - 150, 200
# Clock face: sagging disc
clock_w, clock_h = 120, 100
draw.ellipse([(cx-clock_w, cy-clock_h), (cx+clock_w, cy+clock_h)],
outline=INK, width=6, fill=(243, 251, 238))
# Crosshatch fill inside clock
draw_crosshatch(draw, cx-clock_w+10, cy-clock_h+10,
2*clock_w-20, clock_h*2-20, spacing=10, alpha=120)
# Numbers (dripping)
try:
font = ImageFont.truetype("/System/Library/Fonts/Georgia.ttf", 18)
except:
font = ImageFont.load_default()
draw.text((cx-5, cy-clock_h-20), "12", fill=INK, font=font, anchor="mm")
draw.text((cx+clock_w+15, cy), "3", fill=INK, font=font, anchor="mm")
draw.text((cx-clock_w-15, cy), "9", fill=INK, font=font, anchor="mm")
# Short hand
hx, hy = cx - 40, cy - 30
draw_thick_line(draw, cx, cy, hx, hy, width=5)
# LONG melted hand: thick, dripping down to the chamber floor
path = [(cx, cy),
(cx+60, cy+80),
(cx+20, cy+180),
(cx+40, cy+280),
(cx+10, cy+350),
(cx+5, cy+400)]
for i in range(len(path)-1):
draw_thick_line(draw, path[i][0], path[i][1], path[i+1][0], path[i+1][1], width=8)
# Puddle at base
puddle_x, puddle_y = cx + 5, 420
draw.ellipse([(puddle_x-30, puddle_y), (puddle_x+30, puddle_y+12)], fill=INK)
def draw_lectern(draw):
"""Draw the lectern with mint seal."""
lx1, ly1 = 300, HEIGHT - 220
lx2, ly2 = 400, HEIGHT - 80
# Trapezoid lectern
draw.polygon([(lx1, ly1), (lx2, ly1), (lx2+40, ly2), (lx1-40, ly2)], fill=INK)
# Mint seal
seal_cx, seal_cy = (lx1+lx2)//2, (ly1+ly2)//2
draw.ellipse([(seal_cx-25, seal_cy-25), (seal_cx+25, seal_cy+25)],
outline=MINT, width=4, fill=None)
def draw_delegate(draw):
"""Draw the flat-faced, unmoved delegate at the lectern."""
dx, dy = WIDTH // 2, HEIGHT // 2 - 40
# Torso (black suit)
draw.rectangle([(dx-50, dy+40), (dx+50, dy+200)], fill=INK)
# Shirt front (cream)
draw.rectangle([(dx-20, dy+40), (dx+20, dy+200)], fill=(243, 251, 238))
# Mint tie
draw.rectangle([(dx-8, dy+40), (dx+8, dy+180)], fill=MINT)
# Neck
draw.rectangle([(dx-18, dy+10), (dx+18, dy+40)], fill=(233, 201, 163))
# Head
draw.ellipse([(dx-40, dy-50), (dx+40, dy+10)], fill=(233, 201, 163))
# Hair (side-parted)
draw.polygon([(dx-40, dy-40), (dx-10, dy-60), (dx+10, dy-60), (dx+40, dy-40), (dx+30, dy-20), (dx-30, dy-20)], fill=INK)
# Glasses
draw.ellipse([(dx-28, dy-15), (dx-12, dy+5)], outline=INK, width=4, fill=None)
draw.ellipse([(dx+12, dy-15), (dx+28, dy+5)], outline=INK, width=4, fill=None)
draw.line([(dx-12, dy-5), (dx+12, dy-5)], fill=INK, width=3)
# Pupils (dead flat)
draw.ellipse([(dx-24, dy-8), (dx-20, dy-4)], fill=INK)
draw.ellipse([(dx+20, dy-8), (dx+24, dy-4)], fill=INK)
# Eyebrows (level, no arch)
draw.line([(dx-32, dy-20), (dx-14, dy-20)], fill=INK, width=4)
draw.line([(dx+14, dy-20), (dx+32, dy-20)], fill=INK, width=4)
# Nose
draw.polygon([(dx-3, dy), (dx+3, dy), (dx, dy+15)], fill=INK)
# Mouth (straight, flat line)
draw.line([(dx-20, dy+25), (dx+20, dy+25)], fill=INK, width=5)
def draw_phone_book(draw):
"""Draw the massive open phone book with pages fluttering."""
pbx, pby = WIDTH // 2, HEIGHT // 2 - 100
pbw, pbh = 240, 160
# Main book block (cream)
draw.rectangle([(pbx-pbw//2, pby), (pbx+pbw//2, pby+pbh)], fill=(243, 251, 238), outline=INK, width=5)
# Pages (horizontal lines for text)
for i in range(1, 15):
y = pby + (pbh * i // 16)
draw.line([(pbx-pbw//2+15, y), (pbx+pbw//2-15, y)], fill=INK, width=2)
# Open covers (fanned out)
# Left cover
cover_pts = [(pbx-pbw//2, pby), (pbx-pbw//2-50, pby-50), (pbx-pbw//2-40, pby+pbh), (pbx-pbw//2, pby+pbh)]
draw.polygon(cover_pts, fill=(243, 251, 238), outline=INK)
# Right cover
cover_pts = [(pbx+pbw//2, pby), (pbx+pbw//2+50, pby-50), (pbx+pbw//2+40, pby+pbh), (pbx+pbw//2, pby+pbh)]
draw.polygon(cover_pts, fill=(243, 251, 238), outline=INK)
# "TELEPHONE DIRECTORY" label
try:
font = ImageFont.truetype("/System/Library/Fonts/Georgia.ttf", 14)
except:
font = ImageFont.load_default()
draw.text((pbx-30, pby+pbh//2-20), "TELEPHONE", fill=INK, font=font)
draw.text((pbx-30, pby+pbh//2+10), "DIRECTORY", fill=INK, font=font)
def draw_fluttering_pages(draw):
"""Draw scattered, fluttering phone-book pages."""
page_positions = [
(200, 350, 30), (350, 400, -25), (250, 500, 20),
(750, 350, 15), (900, 420, -30), (800, 520, 25),
]
for px, py, angle in page_positions:
# Small rectangle for page
w, h = 50, 35
draw.rectangle([(px-w//2, py-h//2), (px+w//2, py+h//2)],
fill=(243, 251, 238), outline=INK, width=3)
# Lines (text)
for i in range(3):
draw.line([(px-w//2+8, py-h//2+10+i*8), (px+w//2-8, py-h//2+10+i*8)], fill=INK, width=1)
def draw_frame(draw):
"""Draw classic editorial cartoon double-rule frame."""
# Outer frame
draw.rectangle([(10, 10), (WIDTH-10, HEIGHT-10)], outline=INK, width=12)
# Inner frame
draw.rectangle([(30, 30), (WIDTH-30, HEIGHT-30)], outline=INK, width=2)
def draw_caption(draw):
"""Draw the title caption inside the frame."""
try:
title_font = ImageFont.truetype("/System/Library/Fonts/Georgia.ttf", 42)
subtitle_font = ImageFont.truetype("/System/Library/Fonts/Georgia.ttf", 16)
except:
title_font = ImageFont.load_default()
subtitle_font = ImageFont.load_default()
# Title at bottom
title_x = WIDTH // 2
title_y = HEIGHT - 80
draw.text((title_x, title_y), '"THE FILIBUSTER MARATHON"',
fill=MINT, font=title_font, anchor="mm")
# Subtitle
draw.text((title_x, title_y + 45),
'Reading, uninterrupted, since the numbers began to melt.',
fill=INK, font=subtitle_font, anchor="mm")
# Build the cartoon
draw_chamber_of_congress(draw)
draw_dark_chamber(draw)
draw_melted_clock(draw)
draw_lectern(draw)
draw_delegate(draw)
draw_phone_book(draw)
draw_fluttering_pages(draw)
draw_frame(draw)
draw_caption(draw)
# Save
img.save('filibuster-marathon-conrad.jpg', quality=95)
print("✓ Generated: filibuster-marathon-conrad.jpg")
img.show()