Last active
June 6, 2026 21:45
-
-
Save c0dezer019/b2247a8930c99d7aa3b788841b912039 to your computer and use it in GitHub Desktop.
Transform images into images or an html page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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("&", "&") | |
| .replace("<", "<") | |
| .replace(">", ">") | |
| ) | |
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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