I’ve always enjoyed adding little “mini-projects” to my tool belt especially ones that combine clean code with a bit of visual flair. Recently I needed a dead-simple CAPTCHA for a hobby site, so I reached for the underrated captcha
Python library and ended up writing a tiny CLI that now lives in my dotfiles.
- Walk through the original 30-line snippet line-by-line so you can see exactly what every import and call does.
- Show you my extended, production-ready version: command-line switches, automatic verification, custom fonts/colours/noise, in-memory handling, and batch mode everything I wished the demo had on day one.
- Share next-step ideas (FastAPI micro-service, audio CAPTCHAs, adversarial OCR tests) and wrap up with a few lessons learned.
Deep-dive into the “Hello World” CAPTCHA
Before I rewrote anything, I wanted to know where every byte came from. Here’s the tiniest viable script, followed by a table-style breakdown:
from PIL import Image
import random, string
def generate_captcha_text(length: int = 6) -> str:
chars = string.ascii_letters + string.digits
return ''.join(random.choices(chars, k=length))
def generate_captcha_image(captcha_text: str,
image_width: int = 300) -> str:
image = ImageCaptcha(width=image_width)
file_name = f"{captcha_text}.png"
image.write(captcha_text, file_name)
return file_name
if __name__ == "__main__":
text = generate_captcha_text()
file_path = generate_captcha_image(text)
print(f"Generated CAPTCHA text: {text}")
Image.open(file_path).show()
# | Code | What really happens |
---|---|---|
1 | from captcha.image import ImageCaptcha | Pulls in the renderer that bends, warps, and sprinkles noise on text simple OCR dies instantly. |
2 | from PIL import Image | Pillow lets me open/preview the PNG that ImageCaptcha.write() just saved. |
3 | import random, string | Core helpers: random.choices() (sampling with replacement) and string.ascii_letters + string.digits (62-char pool). |
4-9 | generate_captcha_text() | 1) Builds the pool. 2) Picks length symbols uniformly. 3) Returns a contiguous string such as A9cV4K . |
11-19 | generate_captcha_image() | 1) Instantiates the renderer (I can also tweak height/fonts/colours/noise).\ 2) write() rasterises & saves the PNG. |
21-31 | __main__ guard | Random text → image → debug print. Finally Image.open().show() asks my OS to preview the file. |
Fast tweaks
What I changed | One-liner |
---|---|
8-char puzzle | generate_captcha_text(8) |
Custom size/font | ImageCaptcha(width=280, height=90, fonts=['./Roboto-Bold.ttf']) |
Black on white, 40 % noise | ImageCaptcha(color='black', background='white', noise_level=0.4) |
Level-up: the captcha_tool.py
CLI
One-offs are fun, but I wanted a Swiss-Army knife:
- Flags so I can whip up 5 CAPTCHAs in one go (
--count 5
). - Verification loop when I’m demoing to friends (
--verify
). - Memory-only mode for server responses no temp files (
--nodisk
). - House-keeping so PNGs older than n minutes vanish automatically.
- Colour / font / noise knobs because brand guidelines are real.
Below is the trimmed-down version (full gist at the end). Copy-paste, rename to captcha_tool.py
, and you’re off.
\captcha_tool.py
Generate and optionally verify CAPTCHA challenges from the command line.
"""
import argparse, io, random, string, sys
from datetime import datetime, timedelta
from pathlib import Path
from captcha.image import ImageCaptcha
from PIL import Image
# ---------- helpers ---------- #
def random_text(length: int) -> str:
pool = string.ascii_letters + string.digits
return "".join(random.choices(pool, k=length))
def draw_captcha(text: str,
width: int = 300,
height: int = 100,
color: str = "black",
background: str = "white",
noise: float = 0.4,
fonts: list[str] | None = None,
to_disk: bool = True) -> tuple[str, bytes | None]:
gen = ImageCaptcha(width=width, height=height,
fonts=fonts,
color=color, background=background,
noise_level=noise)
if to_disk:
name = f"{datetime.utcnow().timestamp()}_{text}.png"
gen.write(text, name)
return name, None
buf = io.BytesIO()
gen.write(text, buf)
return f"mem://{text}", buf.getvalue()
# ---------- CLI entry-point ---------- #
def main() -> None:
p = argparse.ArgumentParser(description="Quick CAPTCHA generator")
p.add_argument("--len", type=int, default=6, help="char length")
p.add_argument("--count", type=int, default=1, help="# captchas")
p.add_argument("--verify", action="store_true", help="ask user to solve")
p.add_argument("--nodisk", action="store_true", help="keep in memory")
p.add_argument("--clean-after", type=int, default=15,
help="delete PNGs older than X minutes")
args = p.parse_args()
challenges: list[tuple[str, str]] = []
for _ in range(args.count):
ans = random_text(args.len)
fname, _ = draw_captcha(ans, to_disk=not args.nodisk)
print(f"[+] CAPTCHA ready: {fname}")
challenges.append((ans, fname))
if args.verify:
for ans, fname in challenges:
if not args.nodisk:
Image.open(fname).show()
guess = input("Text you see → ").strip()
print("✅ Correct!\n" if guess == ans else f"❌ Nope (was {ans})\n")
if not args.nodisk and args.clean_after > 0:
cut = datetime.utcnow() - timedelta(minutes=args.clean_after)
for png in Path(".").glob("*.png"):
if datetime.utcfromtimestamp(png.stat().st_mtime) < cut:
png.unlink(missing_ok=True)
if __name__ == "__main__":
main()
Quick demos
Skill I practise | Command I run |
---|---|
CLI flag parsing | python captcha_tool.py --len 8 --count 5 |
User validation | python captcha_tool.py --verify |
Memory-only flow | python captcha_tool.py --nodisk |
Auto cleanup | python captcha_tool.py --clean-after 1 |
Custom styling | Edit draw_captcha() and crank noise / fonts |
Where I’ll take it next
- FastAPI endpoint –
/captcha/new
returns base64 PNG + token,/captcha/verify
checks answer and TTL. - Redis store – token → answer → expiry, so I never keep secrets in RAM longer than necessary.
- Audio fallback –
captcha.audio.AudioCaptcha
for accessibility compliance. - Adversarial OCR tests – pipe PNGs into Tesseract, tighten noise until solve-rate < 1 %.
- Vue widget – tiny component that fetches fresh images with
fetch()
and updates view in-place.
Final thought
I went in needing a single throw-away CAPTCHA; I came out with a reusable CLI and a blueprint for a micro-service. The lesson? Even the tiniest scripts are opportunities to practise clean switches, separation of concerns, and automated clean-up. Next time you’re about to toss a “one-liner” into production, pause, add flags, and you might surprise yourself with how far that extra 30 minutes takes you.