wisamWR commited on
Commit
ffe3e3c
·
0 Parent(s):

Initial: DitherForge API endpoints

Browse files
Files changed (6) hide show
  1. README.md +21 -0
  2. app.py +198 -0
  3. effects.py +94 -0
  4. palettes.py +10 -0
  5. requirements.txt +4 -0
  6. svg_builder.py +96 -0
README.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: DitherForge API
3
+ emoji: ⬛
4
+ colorFrom: green
5
+ colorTo: gray
6
+ sdk: gradio
7
+ sdk_version: "5.6.0"
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # DitherForge API
14
+
15
+ API endpoints for generating ASCII/binary/braille art as SVG — designed for GitHub README embeds.
16
+
17
+ ## Endpoints
18
+
19
+ - `/api/text` — Convert text to styled SVG
20
+ - `/api/avatar` — Convert image URL to ASCII art SVG
21
+ - `/api/banner` — Generate banner from preset parameters
app.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DitherForge API — Gradio app with text, avatar, and banner endpoints."""
2
+
3
+ from __future__ import annotations
4
+ import gradio as gr
5
+ import requests
6
+ from io import BytesIO
7
+ from PIL import Image
8
+ import numpy as np
9
+
10
+ from effects import binary_effect, ascii_effect, braille_effect, image_to_gray
11
+ from svg_builder import text_to_svg
12
+
13
+ STYLES = {"binary": binary_effect, "ascii": ascii_effect, "braille": braille_effect}
14
+ ANIMATIONS = ["none", "reveal", "scanline", "flicker", "matrix"]
15
+
16
+
17
+ def text_endpoint(
18
+ text: str,
19
+ style: str = "ascii",
20
+ color: str = "#00ff41",
21
+ bg_color: str = "#0d1117",
22
+ animation: str = "none",
23
+ duration: float = 2.0,
24
+ font_size: int = 14,
25
+ ) -> tuple[str, str]:
26
+ """Convert text string to styled SVG. Returns (html_preview, svg_source)."""
27
+ effect_fn = STYLES.get(style, ascii_effect)
28
+
29
+ lines = text.split("\n") if "\n" in text else [text]
30
+ max_len = max(len(line) for line in lines)
31
+ h_pixels = len(lines) * 16
32
+ w_pixels = max_len * 10
33
+
34
+ gray = np.zeros((h_pixels, w_pixels), dtype=np.uint8)
35
+ for row_idx, line in enumerate(lines):
36
+ for col_idx, char in enumerate(line):
37
+ brightness = (ord(char) * 37) % 256
38
+ y0, y1 = row_idx * 16, (row_idx + 1) * 16
39
+ x0, x1 = col_idx * 10, (col_idx + 1) * 10
40
+ if y1 <= h_pixels and x1 <= w_pixels:
41
+ gray[y0:y1, x0:x1] = brightness
42
+
43
+ art_text = effect_fn(gray, cols=max_len)
44
+ svg = text_to_svg(art_text, font_size, color, bg_color, animation, duration)
45
+ return svg, svg
46
+
47
+
48
+ def avatar_endpoint(
49
+ image: Image.Image | None = None,
50
+ image_url: str = "",
51
+ style: str = "ascii",
52
+ cols: int = 60,
53
+ color: str = "#00ff41",
54
+ bg_color: str = "#0d1117",
55
+ animation: str = "none",
56
+ duration: float = 2.0,
57
+ ) -> tuple[str, str]:
58
+ """Convert uploaded image or image URL to ASCII art SVG."""
59
+ if image is None and image_url:
60
+ resp = requests.get(image_url, timeout=10)
61
+ resp.raise_for_status()
62
+ image = Image.open(BytesIO(resp.content))
63
+
64
+ if image is None:
65
+ svg = text_to_svg("No image provided", 14, color, bg_color)
66
+ return svg, svg
67
+
68
+ gray = image_to_gray(image)
69
+ effect_fn = STYLES.get(style, ascii_effect)
70
+ art_text = effect_fn(gray, cols=cols)
71
+ svg = text_to_svg(art_text, 14, color, bg_color, animation, duration)
72
+ return svg, svg
73
+
74
+
75
+ def banner_endpoint(
76
+ text: str = "DitherForge",
77
+ style: str = "binary",
78
+ cols: int = 40,
79
+ color: str = "#00ff41",
80
+ bg_color: str = "#0d1117",
81
+ animation: str = "reveal",
82
+ duration: float = 2.0,
83
+ ) -> tuple[str, str]:
84
+ """Generate a banner SVG from parameters."""
85
+ from PIL import ImageDraw, ImageFont
86
+
87
+ font_size = 40
88
+ char_w = font_size * 0.6
89
+ img_w = max(int(len(text) * char_w) + 20, 200)
90
+ img_h = font_size + 20
91
+
92
+ img = Image.new("L", (img_w, img_h), 255)
93
+ draw = ImageDraw.Draw(img)
94
+ try:
95
+ font = ImageFont.truetype(
96
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", font_size
97
+ )
98
+ except (OSError, IOError):
99
+ font = ImageFont.load_default()
100
+
101
+ draw.text((10, 10), text, fill=0, font=font)
102
+ gray = np.array(img, dtype=np.uint8)
103
+
104
+ effect_fn = STYLES.get(style, binary_effect)
105
+ art_text = effect_fn(gray, cols=cols)
106
+ svg = text_to_svg(art_text, 12, color, bg_color, animation, duration)
107
+ return svg, svg
108
+
109
+
110
+ # --- Gradio UI ---
111
+
112
+ with gr.Blocks(title="DitherForge API") as demo:
113
+ gr.Markdown(
114
+ "# DitherForge API\n"
115
+ "Generate ASCII/binary/braille art as SVG for GitHub README embeds."
116
+ )
117
+
118
+ with gr.Tab("Text to SVG"):
119
+ with gr.Row():
120
+ with gr.Column():
121
+ txt_input = gr.Textbox(label="Text", value="Hello World")
122
+ txt_style = gr.Dropdown(
123
+ list(STYLES.keys()), value="ascii", label="Style"
124
+ )
125
+ txt_color = gr.ColorPicker(label="Text Color", value="#00ff41")
126
+ txt_bg = gr.ColorPicker(label="Background", value="#0d1117")
127
+ txt_anim = gr.Dropdown(ANIMATIONS, value="none", label="Animation")
128
+ txt_dur = gr.Slider(
129
+ 0.5, 5.0, value=2.0, step=0.1, label="Duration (s)"
130
+ )
131
+ txt_btn = gr.Button("Generate", variant="primary")
132
+ with gr.Column():
133
+ txt_out = gr.HTML(label="Preview")
134
+ txt_svg = gr.Code(label="SVG Source", language="xml")
135
+
136
+ txt_btn.click(
137
+ text_endpoint,
138
+ [txt_input, txt_style, txt_color, txt_bg, txt_anim, txt_dur],
139
+ [txt_out, txt_svg],
140
+ api_name="text",
141
+ )
142
+
143
+ with gr.Tab("Image to ASCII SVG"):
144
+ with gr.Row():
145
+ with gr.Column():
146
+ img_input = gr.Image(type="pil", label="Upload Image")
147
+ img_url = gr.Textbox(
148
+ label="Or paste image URL", placeholder="https://..."
149
+ )
150
+ img_style = gr.Dropdown(
151
+ list(STYLES.keys()), value="ascii", label="Style"
152
+ )
153
+ img_cols = gr.Slider(20, 120, value=60, step=1, label="Columns")
154
+ img_color = gr.ColorPicker(label="Text Color", value="#00ff41")
155
+ img_bg = gr.ColorPicker(label="Background", value="#0d1117")
156
+ img_anim = gr.Dropdown(ANIMATIONS, value="none", label="Animation")
157
+ img_btn = gr.Button("Generate", variant="primary")
158
+ with gr.Column():
159
+ img_out = gr.HTML(label="Preview")
160
+ img_svg = gr.Code(label="SVG Source", language="xml")
161
+
162
+ img_btn.click(
163
+ avatar_endpoint,
164
+ [img_input, img_url, img_style, img_cols, img_color, img_bg, img_anim],
165
+ [img_out, img_svg],
166
+ api_name="avatar",
167
+ )
168
+
169
+ with gr.Tab("Banner"):
170
+ with gr.Row():
171
+ with gr.Column():
172
+ ban_text = gr.Textbox(label="Banner Text", value="DitherForge")
173
+ ban_style = gr.Dropdown(
174
+ list(STYLES.keys()), value="binary", label="Style"
175
+ )
176
+ ban_cols = gr.Slider(20, 100, value=40, step=1, label="Columns")
177
+ ban_color = gr.ColorPicker(label="Color", value="#00ff41")
178
+ ban_bg = gr.ColorPicker(label="Background", value="#0d1117")
179
+ ban_anim = gr.Dropdown(
180
+ ANIMATIONS, value="reveal", label="Animation"
181
+ )
182
+ ban_dur = gr.Slider(
183
+ 0.5, 5.0, value=2.0, step=0.1, label="Duration (s)"
184
+ )
185
+ ban_btn = gr.Button("Generate", variant="primary")
186
+ with gr.Column():
187
+ ban_out = gr.HTML(label="Preview")
188
+ ban_svg = gr.Code(label="SVG Source", language="xml")
189
+
190
+ ban_btn.click(
191
+ banner_endpoint,
192
+ [ban_text, ban_style, ban_cols, ban_color, ban_bg, ban_anim, ban_dur],
193
+ [ban_out, ban_svg],
194
+ api_name="banner",
195
+ )
196
+
197
+ if __name__ == "__main__":
198
+ demo.launch()
effects.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-Python ports of DitherForge core text effects."""
2
+
3
+ from __future__ import annotations
4
+ import numpy as np
5
+ from PIL import Image
6
+
7
+ DEFAULT_ASCII_CHARSET = " .:-=+*#%@"
8
+
9
+ BRAILLE_BASE = 0x2800
10
+ DOT_BITS = [
11
+ [0x01, 0x08],
12
+ [0x02, 0x10],
13
+ [0x04, 0x20],
14
+ [0x40, 0x80],
15
+ ]
16
+
17
+
18
+ def image_to_gray(img: Image.Image) -> np.ndarray:
19
+ """Convert PIL Image to 2D grayscale numpy array (0-255)."""
20
+ return np.array(img.convert("L"), dtype=np.uint8)
21
+
22
+
23
+ def binary_effect(gray: np.ndarray, cols: int = 80, threshold: int = 128) -> str:
24
+ """Convert grayscale image to binary (0/1) text."""
25
+ h, w = gray.shape
26
+ cols = max(1, min(cols, w))
27
+ block_w = w / cols
28
+ block_h = block_w * 2
29
+ rows = max(1, int(h / block_h))
30
+
31
+ lines = []
32
+ for row in range(rows):
33
+ line = ""
34
+ for col in range(cols):
35
+ x0 = int(col * block_w)
36
+ x1 = int((col + 1) * block_w)
37
+ y0 = int(row * block_h)
38
+ y1 = min(int((row + 1) * block_h), h)
39
+ avg = gray[y0:y1, x0:x1].mean()
40
+ line += "1" if avg >= threshold else "0"
41
+ lines.append(line)
42
+ return "\n".join(lines)
43
+
44
+
45
+ def ascii_effect(
46
+ gray: np.ndarray, cols: int = 80, charset: str = DEFAULT_ASCII_CHARSET
47
+ ) -> str:
48
+ """Convert grayscale image to ASCII art."""
49
+ h, w = gray.shape
50
+ cols = max(1, min(cols, w))
51
+ block_w = w / cols
52
+ block_h = block_w * 2
53
+ rows = max(1, int(h / block_h))
54
+
55
+ lines = []
56
+ for row in range(rows):
57
+ line = ""
58
+ for col in range(cols):
59
+ x0 = int(col * block_w)
60
+ x1 = int((col + 1) * block_w)
61
+ y0 = int(row * block_h)
62
+ y1 = min(int((row + 1) * block_h), h)
63
+ avg = gray[y0:y1, x0:x1].mean()
64
+ idx = min(int((avg / 255) * len(charset)), len(charset) - 1)
65
+ line += charset[idx]
66
+ lines.append(line)
67
+ return "\n".join(lines)
68
+
69
+
70
+ def braille_effect(
71
+ gray: np.ndarray, cols: int = 40, threshold: int = 128
72
+ ) -> str:
73
+ """Convert grayscale image to Unicode braille art."""
74
+ h, w = gray.shape
75
+ cols = max(1, min(cols, w // 2))
76
+ pix_w = cols * 2
77
+ scale = w / pix_w
78
+ pix_h = int(h / scale)
79
+ rows = max(1, pix_h // 4)
80
+
81
+ lines = []
82
+ for row in range(rows):
83
+ line = ""
84
+ for col in range(cols):
85
+ cp = 0
86
+ for dy in range(4):
87
+ for dx in range(2):
88
+ sx = min(int((col * 2 + dx) * scale), w - 1)
89
+ sy = min(int((row * 4 + dy) * scale), h - 1)
90
+ if gray[sy, sx] < threshold:
91
+ cp |= DOT_BITS[dy][dx]
92
+ line += chr(BRAILLE_BASE + cp)
93
+ lines.append(line)
94
+ return "\n".join(lines)
palettes.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ PALETTES = {
2
+ "mono": ["#000000", "#ffffff"],
3
+ "gameboy": ["#0f380f", "#306230", "#8bac0f", "#9bbc0f"],
4
+ "cga": [
5
+ "#000000", "#0000aa", "#00aa00", "#00aaaa",
6
+ "#aa0000", "#aa00aa", "#aa5500", "#aaaaaa",
7
+ "#555555", "#5555ff", "#55ff55", "#55ffff",
8
+ "#ff5555", "#ff55ff", "#ffff55", "#ffffff",
9
+ ],
10
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=5.0.0
2
+ Pillow>=10.0.0
3
+ requests>=2.31.0
4
+ numpy>=1.24.0
svg_builder.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SVG generation with optional CSS animations."""
2
+
3
+ from __future__ import annotations
4
+ from html import escape
5
+
6
+
7
+ def text_to_svg(
8
+ text: str,
9
+ font_size: int = 14,
10
+ color: str = "#00ff41",
11
+ bg_color: str = "#0d1117",
12
+ animation: str = "none",
13
+ duration: float = 2.0,
14
+ ) -> str:
15
+ lines = text.split("\n")
16
+ cols = max((len(line) for line in lines), default=0)
17
+ char_w = font_size * 0.6
18
+ line_h = font_size * 1.2
19
+ svg_w = int(cols * char_w)
20
+ svg_h = int(len(lines) * line_h)
21
+
22
+ parts = [
23
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" '
24
+ f'viewBox="0 0 {svg_w} {svg_h}">',
25
+ f'<rect width="100%" height="100%" fill="{bg_color}"/>',
26
+ ]
27
+
28
+ css = _animation_css(animation, duration, len(lines))
29
+ if css:
30
+ parts.append(f"<style>{css}</style>")
31
+
32
+ use_row_class = animation in ("reveal", "matrix")
33
+
34
+ parts.append(
35
+ f'<text font-family="monospace" font-size="{font_size}" fill="{color}">'
36
+ )
37
+
38
+ for i, line in enumerate(lines):
39
+ y = (i + 1) * line_h - (line_h - font_size) / 2
40
+ cls = f' class="row row-{i}"' if use_row_class else ""
41
+ parts.append(f'<tspan x="0" y="{y:.1f}"{cls}>{escape(line)}</tspan>')
42
+
43
+ parts.append("</text>")
44
+
45
+ if animation == "scanline":
46
+ parts.append(
47
+ f'<rect class="scanline" x="0" y="0" width="100%" '
48
+ f'height="{line_h}" fill="{color}" opacity="0.15"/>'
49
+ )
50
+
51
+ parts.append("</svg>")
52
+ return "".join(parts)
53
+
54
+
55
+ def _animation_css(animation: str, duration: float, rows: int) -> str:
56
+ if animation == "reveal":
57
+ delays = "\n".join(
58
+ f".row-{i} {{ animation-delay: {i * duration / rows:.2f}s; }}"
59
+ for i in range(rows)
60
+ )
61
+ return (
62
+ ".row { opacity: 0; animation: reveal 0.15s ease-out forwards; }\n"
63
+ "@keyframes reveal { from { opacity: 0; } to { opacity: 1; } }\n"
64
+ + delays
65
+ )
66
+
67
+ if animation == "scanline":
68
+ return (
69
+ f"@keyframes scan {{ 0% {{ transform: translateY(-5%); }} "
70
+ f"100% {{ transform: translateY(105%); }} }}\n"
71
+ f".scanline {{ animation: scan {duration}s linear infinite; }}"
72
+ )
73
+
74
+ if animation == "flicker":
75
+ return (
76
+ "@keyframes flicker { 0%,100% { opacity:1; } "
77
+ "92% { opacity:1; } 93% { opacity:0.3; } "
78
+ "94% { opacity:1; } 96% { opacity:0.5; } 97% { opacity:1; } }\n"
79
+ f"text {{ animation: flicker {duration}s ease-in-out infinite; }}"
80
+ )
81
+
82
+ if animation == "matrix":
83
+ delays = "\n".join(
84
+ f".row-{i} {{ animation-delay: {i * duration / rows:.2f}s; }}"
85
+ for i in range(rows)
86
+ )
87
+ return (
88
+ f".row {{ opacity: 0; animation: matrix {duration * 0.3:.2f}s ease-out forwards; }}\n"
89
+ "@keyframes matrix { "
90
+ "0% { opacity:0; transform:translateY(-20px); } "
91
+ "60% { opacity:1; } "
92
+ "100% { opacity:1; transform:translateY(0); } }\n"
93
+ + delays
94
+ )
95
+
96
+ return ""