From 4368bca154d84a76632ecb38d581609ada2d5fe3 Mon Sep 17 00:00:00 2001 From: thedarkknight197 Date: Thu, 28 May 2026 07:37:06 +0100 Subject: [PATCH 1/3] 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/3] 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 From 229c30a173b6a8333cf2b537a504378cc5fb79ad Mon Sep 17 00:00:00 2001 From: thedarkknight197 Date: Thu, 28 May 2026 08:13:25 +0100 Subject: [PATCH 3/3] fix(art): JJN dithering + histogram eq + details block format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements to ASCII art quality and display: 1. img2ascii.py — replace dense 70-char ramp + edge blend with: - Jarvis-Judice-Ninke error diffusion dithering (matches asciiart.eu 'Quality Enhancements: JJN' setting) - Histogram equalization so full contrast range is used regardless of image tone (fixes dark images like red Ferrari going solid-black) - Sparse 8-char ramp '@%#*+:. ' with brightness threshold (default 200) so bright/sky areas stay as spaces - Result: recognizable subjects with light-background aesthetic 2. shared.py — replace HTML comment art block with
block: Previously: Now:
📎 ASCII image
...
- HTML comments with '>' chars from the ramp broke GitHub rendering (comment closed early, rest leaked as visible markdown) -
renders correctly on GitHub.com as a collapsible section - Backward compat regex kept for reading old HTML-comment posts 3. post.py — art generation now at width=120 (was 100) for better detail --- wayd/scripts/img2ascii.py | 129 ++++++++++++++++---------------------- wayd/scripts/post.py | 2 +- wayd/scripts/shared.py | 37 ++++++++--- 3 files changed, 81 insertions(+), 87 deletions(-) diff --git a/wayd/scripts/img2ascii.py b/wayd/scripts/img2ascii.py index d461ed9..284d90d 100644 --- a/wayd/scripts/img2ascii.py +++ b/wayd/scripts/img2ascii.py @@ -1,26 +1,15 @@ #!/usr/bin/env python3 """Convert an image to high-quality ASCII art for WAYD posts. -Produces results comparable to asciiart.eu: edge-enhanced, sharpened, -contrast-boosted, with a dense 70-char gradient. +Uses Jarvis-Judice-Ninke error diffusion dithering with a sparse character +set and brightness threshold — matching the asciiart.eu aesthetic: +light/background areas become spaces, only edges and shadows get chars. Usage: - img2ascii.py --image PATH [--width N] [--invert] [--caption TEXT] - [--edge-weight F] [--sharpen] [--contrast F] + img2ascii.py --image PATH [--width N] [--invert] [--threshold N] + [--contrast F] [--caption TEXT] Prints JSON to stdout: {"ok": true, "art": "...", "chars": N} - -Options: - --image PATH Image file (JPEG, PNG, GIF, WebP, DNG, …) - --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). - --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: 2.2). 1.0 = no change. """ from __future__ import annotations @@ -32,45 +21,23 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import shared # noqa: E402 -# 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, 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))] +# Sparse ramp dark→light. Last entry is space (bright pixels). +_RAMP_DARK = "@%#*+:. " +_RAMP_LIGHT = _RAMP_DARK[::-1] def image_to_ascii( image_path: str, - width: int = 80, + width: int = 120, invert: bool = False, - threshold: int = 140, - edge_weight: float = 0.3, - sharpen: bool = True, - contrast: float = 2.2, + contrast: float = 1.5, + threshold: int = 200, caption: str = "", max_chars: int | None = None, ) -> str: - """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. - """ + """Return ASCII art string using JJN dithering + threshold. Raises ValueError on failure.""" try: - from PIL import Image, ImageEnhance, ImageFilter # type: ignore[import] + from PIL import Image, ImageEnhance, ImageOps # type: ignore[import] except ImportError: raise ValueError("Pillow is required: pip install Pillow") @@ -83,14 +50,14 @@ def image_to_ascii( img = img.convert("RGB") - if sharpen: - img = ImageEnhance.Sharpness(img).enhance(3.0) if contrast != 1.0: img = ImageEnhance.Contrast(img).enhance(contrast) gray = img.convert("L") - # Terminal chars are ~2:1 tall:wide; 0.45 corrects aspect ratio. + # Equalize histogram so the full 0-255 range is used regardless of image tone + gray = ImageOps.equalize(gray) + aspect = img.height / img.width height = max(1, int(width * aspect * 0.45)) @@ -102,26 +69,41 @@ def image_to_ascii( width = int(width * 0.9) height = max(1, int(width * aspect * 0.45)) - base = gray.resize((width, height), Image.LANCZOS) - edge_src = gray.filter(ImageFilter.GaussianBlur(1)).filter(ImageFilter.FIND_EDGES) - edges = edge_src.resize((width, height), Image.LANCZOS) + gray = gray.resize((width, height), Image.LANCZOS) - base_px = base.tobytes() - edge_px = edges.tobytes() + ramp = _RAMP_LIGHT if invert else _RAMP_DARK + n_chars = len(ramp) - 1 # last slot = space, reserved for threshold + step = threshold / n_chars - chars = _CHARS_LIGHT if invert else _CHARS_DARK - lines: list[str] = [] + px = [[float(gray.getpixel((x, y))) for x in range(width)] for y in range(height)] - for row in range(height): - row_chars = [] - for col in range(width): - idx = row * width + col - b = base_px[idx] - e = edge_px[idx] - blended = int(b * (1 - edge_weight) + (255 - e) * edge_weight) - blended = max(0, min(255, blended)) - row_chars.append(_px(blended, threshold, chars)) - lines.append("".join(row_chars).rstrip()) # trim trailing spaces + lines: list[str] = [] + for y in range(height): + row: list[str] = [] + for x in range(width): + old = max(0.0, min(255.0, px[y][x])) + + if old >= threshold: + # Bright pixel → space, distribute error + err = old - 255.0 + char = " " + else: + level = min(n_chars - 1, int(old / step)) + err = old - level * step + char = ramp[level] + + # Jarvis-Judice-Ninke error diffusion kernel (denominator 48) + for dy, dx, w in ( + (0, 1, 7), (0, 2, 5), + (1, -2, 3), (1, -1, 5), (1, 0, 7), (1, 1, 5), (1, 2, 3), + (2, -2, 1), (2, -1, 3), (2, 0, 5), (2, 1, 3), (2, 2, 1), + ): + ny, nx = y + dy, x + dx + if 0 <= ny < height and 0 <= nx < width: + px[ny][nx] += err * w / 48.0 + + row.append(char) + lines.append("".join(row).rstrip()) art = "\n".join(lines) if caption: @@ -130,16 +112,13 @@ def image_to_ascii( def main() -> None: - parser = argparse.ArgumentParser(description="High-quality image → ASCII art.") + parser = argparse.ArgumentParser(description="High-quality image → ASCII art (JJN dithering).") parser.add_argument("--image", required=True) - parser.add_argument("--width", type=int, default=80) + parser.add_argument("--width", type=int, default=120) parser.add_argument("--invert", action="store_true") parser.add_argument("--caption", default="") - 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=2.2) + parser.add_argument("--contrast", type=float, default=1.5) + parser.add_argument("--threshold", type=int, default=200) parser.add_argument("--max-chars", type=int, default=None) args = parser.parse_args() @@ -148,10 +127,8 @@ 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, + threshold=args.threshold, caption=args.caption, max_chars=args.max_chars, ) diff --git a/wayd/scripts/post.py b/wayd/scripts/post.py index 57f0622..1473dc1 100644 --- a/wayd/scripts/post.py +++ b/wayd/scripts/post.py @@ -98,7 +98,7 @@ def _convert_image_to_art(image_path: str, text_len: int, max_chars: int) -> str return None try: - return image_to_ascii(image_path=image_path) + return image_to_ascii(image_path=image_path, width=120) except Exception as exc: shared.log_error(f"img2ascii failed for {image_path!r}: {exc}") return None diff --git a/wayd/scripts/shared.py b/wayd/scripts/shared.py index 7f1ee73..4872774 100644 --- a/wayd/scripts/shared.py +++ b/wayd/scripts/shared.py @@ -266,13 +266,17 @@ def remove_blocked(username: str) -> bool: MARKER_RE = re.compile(r"") -# Matches the ASCII art block: +# Matches the ASCII art block in
format: +#
...
...
...
# Group 1 = the art content (may be multi-line). ART_MARKER_RE = re.compile( - r"", + r"
.*?
(.*?)
.*?
", re.DOTALL, ) +# Also match the old HTML-comment format for backward compat with old posts. +_ART_COMMENT_RE = re.compile(r"", re.DOTALL) + def build_post_title(vibe_slug: str, vibe_emoji: str, body: str) -> str: """Build the issue title: '[ ] '.""" @@ -290,30 +294,43 @@ def build_post_body(vibe_slug: str, text: str, marker_version: str = "v1") -> st def build_post_body_with_art( vibe_slug: str, text: str, art: str, marker_version: str = "v1" ) -> str: - """Build issue body with embedded ASCII art in an HTML comment block. + """Build issue body with ASCII art in a collapsible
block. - The art block is invisible when GitHub renders the issue but parseable - by scroll.py. Format: + Visible on GitHub as '📎 ASCII image' (click to expand), and parseable + by scroll.py via ART_MARKER_RE. Uses
 for monospace rendering.
+    Format:
         
 
-        
+        
+ +
+ """ - art_block = f"" + art_block = f"
\n📎 ASCII image\n\n
\n{art}\n
\n\n
" return f"{text.strip()}\n\n{art_block}\n\n" def extract_art(body: str) -> str | None: """Return the ASCII art embedded in body, or None if not present.""" m = ART_MARKER_RE.search(body) - return m.group(1) if m else None + if m: + return m.group(1) + # Backward compat: old posts used HTML comment format + m2 = _ART_COMMENT_RE.search(body) + return m2.group(1) if m2 else None def strip_art(body: str) -> str: """Return body with the ASCII art block removed (for display purposes).""" - return ART_MARKER_RE.sub("", body).strip() + body = ART_MARKER_RE.sub("", body) + body = _ART_COMMENT_RE.sub("", body) + return body.strip() def parse_post_body(body: str) -> dict[str, Any]: