Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 42 additions & 35 deletions wayd/scripts/img2ascii.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@

Options:
--image PATH Image file (JPEG, PNG, GIF, WebP, DNG, …)
--width N Width in chars (default: 100). Height auto-calculated.
--width N Width in chars (default: 80). Height auto-calculated.
--invert Invert brightness (for light-background images).
--caption TEXT Text appended below the art (2 blank lines separator).
--edge-weight F How much edge detection to blend in, 0.0–1.0 (default 0.4).
Higher = more defined outlines like asciiart.eu.
--threshold N Brightness cutoff 0-255; pixels above this become spaces
(default: 140). Lower = more sparse; higher = denser.
--edge-weight F How much edge detection to blend in, 0.0–1.0 (default 0.3).
--sharpen Apply sharpening before conversion (default: on).
--no-sharpen Disable sharpening.
--contrast F Contrast multiplier (default: 1.3). 1.0 = no change.
--contrast F Contrast multiplier (default: 2.2). 1.0 = no change.
"""

from __future__ import annotations
Expand All @@ -31,27 +32,43 @@
sys.path.insert(0, str(Path(__file__).resolve().parent))
import shared # noqa: E402

# Dense 70-char gradient from dark to light (dark terminal).
# Derived from the classic Paulm gradient used by most quality converters.
_RAMP_DARK = r'$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`\'. '
_RAMP_LIGHT = _RAMP_DARK[::-1]
# Sparse character set — dark to light (last char = space for bright areas).
# Matches asciiart.eu "Space Density 6" aesthetic: lots of whitespace,
# dense chars only in shadows/edges. Far more legible than a 70-char ramp.
_CHARS_DARK = "@%#*+=-:. "
_CHARS_LIGHT = _CHARS_DARK[::-1]


def _px(luminance: int, ramp: str) -> str:
return ramp[int(luminance / 255 * (len(ramp) - 1))]
def _px(luminance: int, threshold: int, chars: str) -> str:
"""Return a character for a given pixel luminance.

Pixels brighter than `threshold` become spaces (light/sky areas).
Darker pixels are mapped across the non-space portion of `chars`.
"""
if luminance >= threshold:
return " "
idx = int(luminance / threshold * (len(chars) - 2))
return chars[max(0, min(len(chars) - 2, idx))]


def image_to_ascii(
image_path: str,
width: int = 100,
width: int = 80,
invert: bool = False,
edge_weight: float = 0.4,
threshold: int = 140,
edge_weight: float = 0.3,
sharpen: bool = True,
contrast: float = 1.3,
contrast: float = 2.2,
caption: str = "",
max_chars: int | None = None,
) -> str:
"""Return ASCII art string. Raises ValueError on failure."""
"""Return ASCII art string. Raises ValueError on failure.

Uses a sparse character set with a brightness threshold — pixels above
the threshold become spaces, producing the airy asciiart.eu aesthetic
where light areas (sky, backgrounds) are empty and only shadows/edges
carry characters.
"""
try:
from PIL import Image, ImageEnhance, ImageFilter # type: ignore[import]
except ImportError:
Expand All @@ -64,25 +81,19 @@ def image_to_ascii(
except Exception as exc:
raise ValueError(f"Cannot open image: {exc}")

# Convert to RGB then grayscale (handles RGBA, P, CMYK, DNG/TIFF, etc.)
img = img.convert("RGB")

# --- Preprocessing (mimics asciiart.eu quality pipeline) ---

if sharpen:
img = ImageEnhance.Sharpness(img).enhance(2.0)

img = ImageEnhance.Sharpness(img).enhance(3.0)
if contrast != 1.0:
img = ImageEnhance.Contrast(img).enhance(contrast)

gray = img.convert("L")

# Compute target height preserving aspect ratio.
# Terminal chars are ~2:1 tall:wide; 0.45 corrects for that.
# Terminal chars are ~2:1 tall:wide; 0.45 corrects aspect ratio.
aspect = img.height / img.width
height = max(1, int(width * aspect * 0.45))

# Shrink to fit max_chars budget if given (art-only chars, no caption).
if max_chars is not None:
caption_cost = len(caption) + 2 if caption else 0
while width >= 20:
Expand All @@ -91,32 +102,26 @@ def image_to_ascii(
width = int(width * 0.9)
height = max(1, int(width * aspect * 0.45))

# Resize both base image and edge map to the target size.
base = gray.resize((width, height), Image.LANCZOS)

# Edge detection: find edges on a slightly blurred version for clean lines.
edge_src = gray.filter(ImageFilter.GaussianBlur(1)).filter(ImageFilter.FIND_EDGES)
edges = edge_src.resize((width, height), Image.LANCZOS)

# Blend: final_lum = base * (1 - w) + edges * w
# Edge pixels push luminance toward dark (dense chars = outlines).
base_px = base.tobytes()
edge_px = edges.tobytes()

ramp = _RAMP_LIGHT if invert else _RAMP_DARK
chars = _CHARS_LIGHT if invert else _CHARS_DARK
lines: list[str] = []

for row in range(height):
chars = []
row_chars = []
for col in range(width):
idx = row * width + col
b = base_px[idx]
e = edge_px[idx]
# Invert edge contribution so edges → darker chars (denser).
blended = int(b * (1 - edge_weight) + (255 - e) * edge_weight)
blended = max(0, min(255, blended))
chars.append(_px(blended, ramp))
lines.append("".join(chars))
row_chars.append(_px(blended, threshold, chars))
lines.append("".join(row_chars).rstrip()) # trim trailing spaces

art = "\n".join(lines)
if caption:
Expand All @@ -127,13 +132,14 @@ def image_to_ascii(
def main() -> None:
parser = argparse.ArgumentParser(description="High-quality image → ASCII art.")
parser.add_argument("--image", required=True)
parser.add_argument("--width", type=int, default=100)
parser.add_argument("--width", type=int, default=80)
parser.add_argument("--invert", action="store_true")
parser.add_argument("--caption", default="")
parser.add_argument("--edge-weight", type=float, default=0.4)
parser.add_argument("--threshold", type=int, default=140)
parser.add_argument("--edge-weight", type=float, default=0.3)
parser.add_argument("--sharpen", dest="sharpen", action="store_true", default=True)
parser.add_argument("--no-sharpen", dest="sharpen", action="store_false")
parser.add_argument("--contrast", type=float, default=1.3)
parser.add_argument("--contrast", type=float, default=2.2)
parser.add_argument("--max-chars", type=int, default=None)
args = parser.parse_args()

Expand All @@ -142,6 +148,7 @@ def main() -> None:
image_path=args.image,
width=args.width,
invert=args.invert,
threshold=args.threshold,
edge_weight=args.edge_weight,
sharpen=args.sharpen,
contrast=args.contrast,
Expand Down
10 changes: 2 additions & 8 deletions wayd/scripts/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def cmd_check_rate_limit(_: argparse.Namespace) -> None:


def _convert_image_to_art(image_path: str, text_len: int, max_chars: int) -> str | None:
"""Convert image to ASCII art sized to fit within the remaining char budget.
"""Convert image to ASCII art. Art is exempt from max_chars, so no budget cap.

Returns the art string, or None if conversion fails (caller logs the error
and continues without art so the post still goes through).
Expand All @@ -97,14 +97,8 @@ def _convert_image_to_art(image_path: str, text_len: int, max_chars: int) -> str
shared.log_error("img2ascii not found; skipping image conversion")
return None

# Reserve chars for: text + 2 newlines + art block markers (~20 chars overhead)
budget = max_chars - text_len - 20
if budget < 50:
shared.log_error("not enough char budget for ASCII art after text")
return None

try:
return image_to_ascii(image_path=image_path, max_chars=budget)
return image_to_ascii(image_path=image_path)
except Exception as exc:
shared.log_error(f"img2ascii failed for {image_path!r}: {exc}")
return None
Expand Down