Skip to content

Instantly share code, notes, and snippets.

@c0dezer019
Last active June 6, 2026 21:45
Show Gist options
  • Select an option

  • Save c0dezer019/b2247a8930c99d7aa3b788841b912039 to your computer and use it in GitHub Desktop.

Select an option

Save c0dezer019/b2247a8930c99d7aa3b788841b912039 to your computer and use it in GitHub Desktop.
Transform images into images or an html page.
#!/usr/bin/env python3
# flake8: noqa: E501
"""
img2ascii.py — Convert any image to pure colored ASCII art HTML and optionally export as an image.
Usage:
python3 img2ascii.py <input_image> [options]
Options:
-o, --output Output file path (default: <input_name>_ascii.png or .html)
--html Save an HTML file instead of a PNG image
(optionally specify file path)
--img-width Force the pixel width of the output PNG (auto-scales height and text)
--img-height Force the pixel height of the output PNG (auto-scales width and text)
-w, --width Character width of output (default: 350).
If the source image is narrower than --width chars,
it is upsampled first so detail isn't invented from nothing.
-c, --contrast Contrast enhancement multiplier (default: 1.5)
-s, --sharpness Sharpness enhancement multiplier (default: 2.5)
-B, --brightness Brightness enhancement multiplier (default: 1.0)
--min-lum Minimum HLS luminance 0.0-1.0 (default: 0.0)
--saturate Color saturation multiplier (default: 1.0)
-b, --bg HTML background color (default: #000000)
--font-size Font size in px (default: 4.0)
--select Auto-select the text to replicate OS highlight effects
--no-gpu Disables GPU usage for compatibility issues.
-h, --help Show this help message
"""
import sys
import os
import argparse
import colorsys
try:
from PIL import Image, ImageEnhance
except ImportError:
print("Error: Pillow is required. Install it with: pip install Pillow")
sys.exit(1)
try:
from html2image import Html2Image # type: ignore[import-untyped]
except ImportError:
Html2Image = None
ascii_chars = (
"$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. "
)
ascii_chars = ascii_chars[::-1]
def lift_luminance(
r: int, g: int, b: int, min_l: float
) -> tuple[int, int, int]:
if min_l <= 0:
return r, g, b
h, luminance, s = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
if luminance < min_l:
luminance = min_l
nr, ng, nb = colorsys.hls_to_rgb(h, luminance, s)
return int(nr * 255), int(ng * 255), int(nb * 255)
def image_to_ascii_html(
img: Image.Image,
width: int,
contrast: float,
sharpness: float,
brightness: float,
min_lum: float,
saturate: float,
bg_color: str,
font_size: float,
auto_select: bool,
text_scale: float,
) -> str:
if brightness != 1.0:
img = ImageEnhance.Brightness(img).enhance(brightness)
img = ImageEnhance.Contrast(img).enhance(contrast)
if saturate != 1.0:
img = ImageEnhance.Color(img).enhance(saturate)
img = ImageEnhance.Sharpness(img).enhance(sharpness)
aspect = img.height / img.width
height = int(width * aspect * 0.48)
img = img.resize( # type: ignore[arg-type]
(width, height), resample=Image.Resampling.LANCZOS
).convert("RGB")
rows: list[list[tuple[int, int, int, str]]] = []
for y in range(height):
row: list[tuple[int, int, int, str]] = []
for x in range(width):
pixel = img.getpixel((x, y))
if isinstance(pixel, tuple):
r, g, b = int(pixel[0]), int(pixel[1]), int(pixel[2])
else:
p = int(pixel) if pixel is not None else 0
r, g, b = p, p, p
r, g, b = lift_luminance(r, g, b, min_lum)
lum = int(0.299 * r + 0.587 * g + 0.114 * b)
char_idx = int(lum / 255 * (len(ascii_chars) - 1))
row.append((r, g, b, ascii_chars[char_idx]))
rows.append(row)
lines_html: list[str] = []
for row in rows:
spans: list[str] = []
i = 0
while i < len(row):
r, g, b, c = row[i]
run = c
j = i + 1
while j < len(row):
r2, g2, b2, c2 = row[j]
if abs(r2 - r) < 10 and abs(g2 - g) < 10 and abs(b2 - b) < 10:
run += c2
j += 1
else:
break
safe = (
run.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
spans.append(f'<span style="color:rgb({r},{g},{b})">{safe}</span>')
i = j
lines_html.append("".join(spans))
ascii_html = "<br>".join(lines_html)
row_height = (font_size * 0.8) * text_scale
half_row = row_height / 2
selection_css = (
f"""
::selection {{
background: #0058d6 !important;
color: #ffffff !important;
}}
body::after {{
content: "";
position: absolute;
top: 0; left: 0; width: 100vw; height: 100vh;
background: repeating-linear-gradient(
to bottom,
transparent,
transparent {half_row}px,
rgba(0, 0, 0, 0.2) {half_row}px,
rgba(0, 0, 0, 0.2) {row_height}px
);
pointer-events: none;
z-index: 10;
}}
"""
if auto_select
else ""
)
selection_js = (
"""
<script>
var pre = document.querySelector('pre');
var range = document.createRange();
range.selectNodeContents(pre);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
</script>
"""
if auto_select
else ""
)
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
background: {bg_color};
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
overflow: hidden;
position: relative;
}}
pre {{
font-family: 'Courier New', Courier, monospace;
font-size: {font_size}px;
line-height: 0.8;
letter-spacing: 0px;
white-space: pre;
margin: 0;
z-index: 1;
transform: scale({text_scale});
transform-origin: center;
}}
{selection_css}
</style>
</head>
<body>
<pre>{ascii_html}</pre>
{selection_js}
</body>
</html>"""
return html
def main():
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("input", nargs="?")
parser.add_argument("-o", "--output")
parser.add_argument("--html", action="store_true", default=False)
parser.add_argument(
"--img-width",
type=int,
help="Force the pixel width of the output PNG",
)
parser.add_argument(
"--img-height",
type=int,
help="Force the pixel height of the output PNG",
)
parser.add_argument("-w", "--width", type=int, default=350)
parser.add_argument("-c", "--contrast", type=float, default=1.5)
parser.add_argument("-s", "--sharpness", type=float, default=2.5)
parser.add_argument("-B", "--brightness", type=float, default=1.0)
parser.add_argument("--min-lum", type=float, default=0.0)
parser.add_argument("--saturate", type=float, default=1.0)
parser.add_argument("-b", "--bg", default="#000000")
parser.add_argument("--font-size", type=float, default=4.0)
parser.add_argument("--select", action="store_true")
parser.add_argument("--no-gpu", action="store_true", default=False)
parser.add_argument("-h", "--help", action="help")
args = parser.parse_args()
if not args.html:
args.font_size = 6.5
if not args.input or not os.path.exists(args.input):
print("Bummer dude, need a valid input image.")
sys.exit(1)
img = Image.open(args.input)
ext = ".html" if args.html else ".png"
if args.output:
base, given_ext = os.path.splitext(args.output)
output_path = args.output if given_ext else args.output + ext
else:
output_path = os.path.splitext(args.input)[0] + "_ascii" + ext
aspect = img.height / img.width
ascii_height = int(args.width * aspect * 0.48)
# Stripped the +120 padding and locked the font ratio to
# eliminate the black borders
char_width_px = args.font_size * 0.6
line_height_px = args.font_size * 0.8
# +2 safety pixels to prevent sub-pixel cutoff
auto_w = int((args.width * char_width_px) + 2)
auto_h = int((ascii_height * line_height_px) + 2)
scale = 1.0
if args.img_width and not args.img_height:
px_w = args.img_width
px_h = int(px_w * (auto_h / auto_w))
scale = px_w / auto_w
elif args.img_height and not args.img_width:
px_h = args.img_height
px_w = int(px_h * (auto_w / auto_h))
scale = px_h / auto_h
elif args.img_width and args.img_height:
px_w = args.img_width
px_h = args.img_height
scale = min(px_w / auto_w, px_h / auto_h)
else:
px_w = auto_w
px_h = auto_h
print("Carving the HTML wave...")
html = image_to_ascii_html(
img,
args.width,
args.contrast,
args.sharpness,
args.brightness,
args.min_lum,
args.saturate,
args.bg,
args.font_size,
args.select,
scale,
)
if args.html:
html_path = os.path.splitext(output_path)[0] + ".html"
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"HTML locked in at: {html_path}")
if not args.html:
if Html2Image is None:
print(
"Gnarly wipeout, comrad! You need html2image to save a PNG. "
"Run: pip install html2image"
)
else:
img_out = output_path
print(f"Snapping the PNG photo to {img_out} at {px_w}x{px_h}...")
if not args.no_gpu:
hti = Html2Image(
custom_flags=[
"--hide-scrollbars",
"--no-sandbox",
"--disable-setuid-sandbox",
]
)
else:
hti = Html2Image(
custom_flags=[
"--hide-scrollbars",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
]
)
hti.screenshot( # type: ignore[misc]
html_str=html,
save_as=os.path.basename(img_out),
size=(px_w, px_h),
)
if os.path.dirname(img_out):
os.rename(os.path.basename(img_out), img_out)
print("Image generated, stay frosty.")
if __name__ == "__main__":
main()
lock-version = "1.0"
created-by = "pip"
[[packages]]
name = "certifi"
version = "2026.5.20"
[[packages.wheels]]
name = "certifi-2026.5.20-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"
[[packages]]
name = "charset-normalizer"
version = "3.4.7"
[[packages.wheels]]
name = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
[packages.wheels.hashes]
sha256 = "bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"
[[packages]]
name = "html2image"
version = "2.0.7"
[[packages.wheels]]
name = "html2image-2.0.7-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/52/ee/967dd1f3ee63c56288ddcb6ceccecf0ce7e0a2fb0fb519c4e4013d7333e5/html2image-2.0.7-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "1806df797e8320ba3246f45149e7034314911be6a285787cbff3edf5bbe9de40"
[[packages]]
name = "idna"
version = "3.18"
[[packages.wheels]]
name = "idna-3.18-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"
[[packages]]
name = "pillow"
version = "12.2.0"
[[packages.wheels]]
name = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl"
url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl"
[packages.wheels.hashes]
sha256 = "4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"
[[packages]]
name = "requests"
version = "2.34.2"
[[packages.wheels]]
name = "requests-2.34.2-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"
[[packages]]
name = "urllib3"
version = "2.7.0"
[[packages.wheels]]
name = "urllib3-2.7.0-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"
[[packages]]
name = "websocket-client"
version = "1.9.0"
[[packages.wheels]]
name = "websocket_client-1.9.0-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
certifi==2026.5.20
charset-normalizer==3.4.7
html2image==2.0.7
idna==3.18
pillow==12.2.0
requests==2.34.2
urllib3==2.7.0
websocket-client==1.9.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment