Sub-character ASCII rendering — picks characters by cell SHAPE, not just brightness. Python port of Alex Harri's algorithm.
Project description
Img2ContourAscii
A command-line image-to-ASCII converter that picks characters based on shape, not just brightness. Edges and contours are followed accurately because each character is matched against the actual visual structure of the image region it occupies.
Credit
This is a Python port of an idea and algorithm created entirely by Alex Harri. The approach is documented in detail in his blog post:
ASCII characters are not pixels: a deep dive into ASCII rendering
The original TypeScript implementation is part of his website's open source repository:
Alex was not involved in this project. All credit for the core algorithm belongs to him.
AI Generated
This implementation was written by Claude (Anthropic) based on Alex Harri's blog post and reference TypeScript source. No code was written by hand.
How it works
The problem with brightness-only rendering
Traditional ASCII renderers assign a character to each grid cell based on average brightness alone — effectively treating characters as square pixels. This produces blurry edges because the shape of the character is ignored.
Shape vectors
Each ASCII character occupies a cell differently. T is dense at the top, L is dense along the left and bottom, / is dense diagonally. Alex Harri's approach captures this by defining six sampling circles arranged across each cell:
(●) (●) ← top row (staggered for better coverage)
(●) (●) ← middle row
(●) (●) ← bottom row
For each character in the alphabet, the fraction of ink inside each circle is measured and stored as a 6-dimensional shape vector. These are pre-computed in default.json (taken directly from Alex's repository).
Matching image cells to characters
The same six circles are sampled at each grid cell in the image to produce a 6D sampling vector. A KD-tree nearest-neighbour search finds the character whose shape vector is closest — the character that best fits the image region's structure.
Contrast enhancement
Two contrast passes sharpen boundaries between regions:
-
Directional crunch — Ten additional circles sample just outside the current cell's boundary. If a neighbouring region is brighter, the corresponding internal component is pushed down, exaggerating the boundary shape and preventing staircase artefacts.
-
Global crunch — The sampling vector is normalised by its own maximum, raised to a power, then rescaled. Increases contrast between lighter and darker components without affecting uniform regions.
Performance
Sampling is fully vectorised with numpy — no Python pixel loops. Results are cached using a quantised lookup table (Alex's own approach from the appendix of his post), so repeated or similar cell values are O(1) dict hits after warmup. This makes live video viable on a Raspberry Pi 4.
Colour (extra)
The --color flag adds ANSI 24-bit colour codes to each character using the average colour of the corresponding image region.
Palette quantisation (1.1+)
The --palette-size N flag (which implies --color) builds a per-image palette of N colours via PIL's median-cut algorithm and snaps every cell's colour to its nearest palette entry. The output then contains at most N unique colours, picked to fit the image's actual content.
This is useful when the output is being fed into a downstream renderer that pays per colour change — e.g. terminal protocols that emit a fresh escape sequence on every cell — or when you simply want a stylised low-palette look. Adjacent cells frequently land on the same palette entry, which collapses long colour-escape runs.
--hysteresis F (default 0.0) layers a sticky-colour bias on top of the palette quantisation. After nearest-palette assignment, a left-to-right per-row pass swaps each cell's colour to the previous cell's colour when that previous palette entry is within (1 + F) of the nearest entry's distance. Higher values produce longer runs of identical colour at the cost of slightly less faithful per-cell hue. 0.5 is a reasonable starting point for natural photographs; 0.0 disables the bias entirely.
When --palette-size is omitted (or 0), colour output is identical to v1.0 — the original 16-step quantisation + saturation boost — so existing callers continue to work unchanged.
Installation
The image renderer is installable from this repo as a Python package
that drops a img2contourascii console command on your PATH and
exposes Renderer / convert() for use as a library.
# Latest from GitHub
pip install git+https://github.com/JamesM92/Img2contourascii
# Or, from a local clone
git clone https://github.com/JamesM92/Img2contourascii
pip install ./Img2contourascii
numpy, Pillow, and scipy are pulled in automatically.
For video / webcam support (separate script, not part of the installed package — install only what you need):
pip install imageio # GIF and basic formats
pip install imageio[ffmpeg] # MP4, AVI, MKV, etc.
pip install picamera2 # Raspberry Pi Camera Module
Image rendering — img2contourascii CLI
img2contourascii <image> [options]
(The legacy invocation python Img2ContourAscii.py <image> [options]
still works — it's a one-line shim that calls into the installed
package, kept for backwards compatibility with existing scripts.)
| Option | Default | Description |
|---|---|---|
--cols N |
terminal width | Output width in characters |
--global-crunch F |
2.2 |
Global contrast exponent |
--directional-crunch F |
2.8 |
Directional contrast exponent |
--color |
off | ANSI 24-bit colour output |
--palette-size N (1.1+) |
unset | Limit colour output to N image-adaptive palette entries (median-cut). Implies --color. |
--hysteresis F (1.1+) |
0.0 |
When --palette-size is set, bias adjacent cells toward the same palette entry. 0.5 is a good starting point for photos. |
--invert |
off | Invert lightness (bright→sparse, dark→dense — useful for photos) |
--autocontrast |
off | Stretch luminance range to [0, 1] before rendering |
--char-ratio F |
1.3333 |
Cell height/width ratio — tune if output looks squished |
--exclude CHARS |
"" |
Characters to never use, e.g. --exclude "|$" |
-o [FILE] |
stdout | Write to file; omit filename to auto-generate |
Backwards compatibility: every existing flag still defaults to its v1.0 behaviour. Omit
--palette-sizeand the colour pipeline is byte-for-byte identical to earlier releases. The new options are purely additive.
Examples
# Basic render to terminal
img2contourascii photo.jpg
# Wider, colour, saved to file
img2contourascii photo.jpg --cols 120 --color -o
# Photo-friendly (dark areas become dense)
img2contourascii photo.jpg --invert --autocontrast
# Higher contrast
img2contourascii photo.jpg --global-crunch 3.0 --directional-crunch 3.5
# Stylised low-palette render — 16 image-adaptive colours, sticky
# enough that adjacent cells often share a colour. Smaller output
# bytes when fed through a downstream renderer.
img2contourascii photo.jpg --palette-size 16 --hysteresis 0.5
Python API
Renderer and the convert() shorthand can be imported directly for use in other scripts:
from img2contourascii import Renderer, convert
# One-shot helper — opens the image, renders, returns a string
text = convert("photo.jpg", cols=80, use_color=True, autocontrast=True)
# Reusable renderer — build once, call render_frame() per image/frame
import numpy as np
from PIL import Image
renderer = Renderer(cols=120, use_color=True, autocontrast=True)
img_arr = np.array(Image.open("photo.jpg").convert("RGB"), dtype=np.float32)
text = renderer.render_frame(img_arr)
# Low-palette render via the Renderer constructor (1.1+)
renderer = Renderer(
cols = 80,
use_color = True,
palette_size = 16, # adaptive 16-colour palette
hysteresis = 0.5, # sticky-colour bias, longer runs
)
text = renderer.render_frame(img_arr)
Video / webcam rendering — ContourAscii_Video.py
python ContourAscii_Video.py <file> [options] # video file or GIF
python ContourAscii_Video.py --webcam [options] # USB webcam
python ContourAscii_Video.py --picam [options] # Raspberry Pi Camera Module
| Option | Default | Description |
|---|---|---|
--fps F |
from file / 30 | Override playback FPS |
--loop |
off | Loop video / GIF continuously |
--device N |
0 |
Webcam device index |
--cam-width N |
640 |
Capture width for webcam / Pi Camera |
--cam-height N |
480 |
Capture height for webcam / Pi Camera |
| (all image options) | --cols, --color, --invert, --autocontrast, etc. |
Examples
# Play a video file
python ContourAscii_Video.py clip.mp4 --color
# Loop an animated GIF
python ContourAscii_Video.py animation.gif --loop
# Live webcam at 80 columns
python ContourAscii_Video.py --webcam --cols 80 --color
# Raspberry Pi Camera
python ContourAscii_Video.py --picam --cols 60 --color --invert
The video script imports Renderer from Img2ContourAscii.py — no code is duplicated, and video dependencies are only needed if you actually use that script.
Examples
All still-image renders use --cols 60 --autocontrast.
The globe animation uses --cols 50 --autocontrast.
Each table row shows the plain render (top) and colour render (bottom) for each method.
Apple
| Original | Brightness only | Contour (this tool) |
source |
||
Animated comparison (brightness → brightness+colour → contour → contour+colour):
Photo: Red apple — Abhijit Tembhekar, CC BY 2.0, via Wikimedia Commons.
Cat
| Original | Brightness only | Contour (this tool) |
source |
||
Animated comparison (brightness → brightness+colour → contour → contour+colour):
Photo: Cat portrait. Public domain, via Wikimedia Commons.
Globe (animated source)
The source is a 24-frame spinning-globe GIF (512×512). Each frame is rendered independently and the output GIFs loop at the original frame rate.
| Source GIF | Brightness only | Contour (this tool) |
source |
||
Animation: Spinning globe — Wikiscient, CC BY-SA 3.0, based on NASA Visible Earth imagery (public domain), via Wikimedia Commons.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file img2contourascii-1.1.1.tar.gz.
File metadata
- Download URL: img2contourascii-1.1.1.tar.gz
- Upload date:
- Size: 60.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f436fcbccb2529f5213f1f392f114bc092820f4254499798aba8e0181dc8ee15
|
|
| MD5 |
78f94debe8ceafaaae0d7c0e39c89349
|
|
| BLAKE2b-256 |
543b127196afb06b5208e6e03556ad5cd38247e9b81e9d70d3340c718edcbb1d
|
Provenance
The following attestation bundles were made for img2contourascii-1.1.1.tar.gz:
Publisher:
publish.yml on JamesM92/Img2ContourAscii
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
img2contourascii-1.1.1.tar.gz -
Subject digest:
f436fcbccb2529f5213f1f392f114bc092820f4254499798aba8e0181dc8ee15 - Sigstore transparency entry: 1630896753
- Sigstore integration time:
-
Permalink:
JamesM92/Img2ContourAscii@1fbdbac1ce5698d22616f670739cf8b32d125d9e -
Branch / Tag:
refs/tags/v1.1.1 - Owner: https://github.com/JamesM92
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1fbdbac1ce5698d22616f670739cf8b32d125d9e -
Trigger Event:
push
-
Statement type:
File details
Details for the file img2contourascii-1.1.1-py3-none-any.whl.
File metadata
- Download URL: img2contourascii-1.1.1-py3-none-any.whl
- Upload date:
- Size: 44.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6abdf0523ba8dd5ef009f7d4f1a4bba7947dc8812d9f488c7946c55ae8a8fa4e
|
|
| MD5 |
308db2b5dfaeff147787711ec8175429
|
|
| BLAKE2b-256 |
eb66d525c99440a629d44c803381d332c5e3a2cfb6a06d4427549523d00488de
|
Provenance
The following attestation bundles were made for img2contourascii-1.1.1-py3-none-any.whl:
Publisher:
publish.yml on JamesM92/Img2ContourAscii
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
img2contourascii-1.1.1-py3-none-any.whl -
Subject digest:
6abdf0523ba8dd5ef009f7d4f1a4bba7947dc8812d9f488c7946c55ae8a8fa4e - Sigstore transparency entry: 1630896757
- Sigstore integration time:
-
Permalink:
JamesM92/Img2ContourAscii@1fbdbac1ce5698d22616f670739cf8b32d125d9e -
Branch / Tag:
refs/tags/v1.1.1 - Owner: https://github.com/JamesM92
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1fbdbac1ce5698d22616f670739cf8b32d125d9e -
Trigger Event:
push
-
Statement type: