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, 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