Config-first ASCII → image CAPTCHA generator. Single-file Python + Pillow. Works as a CLI or a library, with deterministic seeding for tests/CI.
Example preview image — output will vary unless you set random_seed or fixed_code.
- Single-file, developer-friendly (simple to customize)
- Fully
config.jsondriven (fonts, noise, palette, seed, output) - Importable API + CLI
- Deterministic mode via
random_seed
pip install Pillow
python captcha.py -c config.json
# (classic still works) python captcha.py -o out.png --print-codeEverything lives in config.json. Drop this next to captcha.py:
{
"output": "out/captcha.png",
"print_code": true,
"data_url_output": "out/captcha_data_url.txt",
"code_length": 6,
"random_seed": 12345,
"fixed_code": null,
"ascii_chars": "$@B%8&WM# ",
"text_to_ascii": {
"font_path": null,
"font_size": 40,
"scale": 2
},
"render": {
"font_path": null,
"font_size": 9,
"spacing": 1,
"noise_lines": 24,
"blur_shapes": 40,
"apply_blur": true,
"extra_noise_shapes": 30,
"pixel_noise_density": 0.04,
"jitter": 1,
"gaussian_blur_radius": 1.1,
"shape_blur_radius": 2.0
}
}Notes (plain English):
random_seed→ lock output for tests (remove in prod)fixed_code→ force a known CAPTCHA (debug only)ascii_chars→ DARK → LIGHT mappingrender.font_size→ DO NOT USE MORE THAN ~20 or readability drops- More noise = harder for bots (and heavier)
from captcha import generate_image, to_data_url img, code = generate_image(length=6, seed=42) img.save("captcha.png") print("solution:", code) # handy for <img src="..."> data_url = to_data_url(img)
import io from fastapi import FastAPI, HTTPException from fastapi.responses import StreamingResponse from captcha import generate_image app = FastAPI() store = {} # replace with Redis/DB in production @app.get("/captcha.png") def new_captcha(): img, code = generate_image(length=6) store["demo"] = code # bind to session/request id in real apps buf = io.BytesIO() img.save(buf, format="PNG"); buf.seek(0) return StreamingResponse(buf, media_type="image/png") @app.post("/verify") def verify(code: str): if not store.get("demo"): raise HTTPException(400, "No captcha in store") ok = code.strip().upper() == store["demo"].upper() store.pop("demo", None) # one-time use return {"ok": ok}
- Prefer a monospace
font_path; keeprender.spacing = 1 - If the image is too messy: lower
pixel_noise_density/blur_shapes - If too clean: increase
noise_lines/extra_noise_shapes - Pair with rate limiting + session binding + short expiry
MIT.