From 4368bca154d84a76632ecb38d581609ada2d5fe3 Mon Sep 17 00:00:00 2001 From: thedarkknight197 Date: Thu, 28 May 2026 07:37:06 +0100 Subject: [PATCH 1/2] fix(img2ascii): sparse threshold approach matching asciiart.eu aesthetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace dense 70-char gradient with threshold-based sparse mapping: - Pixels above threshold (default 140) → space (sky/background areas) - Pixels below threshold → @%#*+=-: character set (shadows/edges only) - Result: airy asciiart.eu style where whitespace creates the image --- wayd/scripts/img2ascii.py | 77 +++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/wayd/scripts/img2ascii.py b/wayd/scripts/img2ascii.py index 712acc2..d461ed9 100644 --- a/wayd/scripts/img2ascii.py +++ b/wayd/scripts/img2ascii.py @@ -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 @@ -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: @@ -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: @@ -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: @@ -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() @@ -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, From b29e4a9e3961f7f44bc403d59640ef675d4b4c84 Mon Sep 17 00:00:00 2001 From: thedarkknight197 Date: Thu, 28 May 2026 07:47:06 +0100 Subject: [PATCH 2/2] fix(post): remove max_chars budget cap for ASCII art Art is embedded in an HTML comment and explicitly exempt from the post character limit. Capping it to the remaining text budget forced portrait images down to ~38 chars wide, producing unreadable output. Remove the budget constraint so img2ascii runs at full quality (80 chars wide by default). --- wayd/scripts/post.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/wayd/scripts/post.py b/wayd/scripts/post.py index 5b9e8df..57f0622 100644 --- a/wayd/scripts/post.py +++ b/wayd/scripts/post.py @@ -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). @@ -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