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
118 changes: 51 additions & 67 deletions wayd/scripts/img2ascii.py
Original file line number Diff line number Diff line change
@@ -1,25 +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: 100). 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.
--sharpen Apply sharpening before conversion (default: on).
--no-sharpen Disable sharpening.
--contrast F Contrast multiplier (default: 1.3). 1.0 = no change.
"""

from __future__ import annotations
Expand All @@ -31,29 +21,23 @@
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;:,"^`\'. '
# Sparse ramp dark→light. Last entry is space (bright pixels).
_RAMP_DARK = "@%#*+:. "
_RAMP_LIGHT = _RAMP_DARK[::-1]


def _px(luminance: int, ramp: str) -> str:
return ramp[int(luminance / 255 * (len(ramp) - 1))]


def image_to_ascii(
image_path: str,
width: int = 100,
width: int = 120,
invert: bool = False,
edge_weight: float = 0.4,
sharpen: bool = True,
contrast: float = 1.3,
contrast: float = 1.5,
threshold: int = 200,
caption: str = "",
max_chars: int | None = None,
) -> str:
"""Return ASCII art string. Raises ValueError on failure."""
"""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")

Expand All @@ -64,25 +48,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)

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.
# 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))

# 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 +69,41 @@ 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)
gray = 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)
ramp = _RAMP_LIGHT if invert else _RAMP_DARK
n_chars = len(ramp) - 1 # last slot = space, reserved for threshold
step = threshold / n_chars

# 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()
px = [[float(gray.getpixel((x, y))) for x in range(width)] for y in range(height)]

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

for row in range(height):
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))
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:
Expand All @@ -125,15 +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=100)
parser.add_argument("--width", type=int, default=120)
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("--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=1.5)
parser.add_argument("--threshold", type=int, default=200)
parser.add_argument("--max-chars", type=int, default=None)
args = parser.parse_args()

Expand All @@ -142,9 +127,8 @@ def main() -> None:
image_path=args.image,
width=args.width,
invert=args.invert,
edge_weight=args.edge_weight,
sharpen=args.sharpen,
contrast=args.contrast,
threshold=args.threshold,
caption=args.caption,
max_chars=args.max_chars,
)
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, width=120)
except Exception as exc:
shared.log_error(f"img2ascii failed for {image_path!r}: {exc}")
return None
Expand Down
37 changes: 27 additions & 10 deletions wayd/scripts/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,17 @@ def remove_blocked(username: str) -> bool:

MARKER_RE = re.compile(r"<!--\s*wayd:(v\d+)\s+([^>]+?)\s*-->")

# Matches the ASCII art block: <!-- wayd:art\n[art lines]\n-->
# Matches the ASCII art block in <details> format:
# <details data-wayd-art>...<pre>...</pre>...</details>
# Group 1 = the art content (may be multi-line).
ART_MARKER_RE = re.compile(
r"<!--\s*wayd:art\n(.*?)\n-->",
r"<details data-wayd-art>.*?<pre>(.*?)</pre>.*?</details>",
re.DOTALL,
)

# Also match the old HTML-comment format for backward compat with old posts.
_ART_COMMENT_RE = re.compile(r"<!--\s*wayd:art\n(.*?)\n-->", re.DOTALL)


def build_post_title(vibe_slug: str, vibe_emoji: str, body: str) -> str:
"""Build the issue title: '[<emoji> <slug>] <preview>'."""
Expand All @@ -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 <details> 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 <pre> for monospace rendering.
Format:
<text>

<!-- wayd:art
<details data-wayd-art>
<summary>📎 ASCII image</summary>

<pre>
[ascii lines]
-->
</pre>

</details>

<!-- wayd:v1 vibe=<slug> -->
"""
art_block = f"<!-- wayd:art\n{art}\n-->"
art_block = f"<details data-wayd-art>\n<summary>📎 ASCII image</summary>\n\n<pre>\n{art}\n</pre>\n\n</details>"
return f"{text.strip()}\n\n{art_block}\n\n<!-- wayd:{marker_version} vibe={vibe_slug} -->"


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]:
Expand Down