← 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()