Akash S P commited on
Commit
17ead0c
·
0 Parent(s):

Hello World !

Browse files

Random Image as a Service — a FastAPI + Gradio server that streams procedurally generated PNG gradients and emoji art on every GET /image request. Nine styles, pool-backed ~1ms responses, and a self-waking embed for Hugging Face Spaces.

.gitignore ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ *.so
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ wheels/
12
+ *.whl
13
+ .eggs/
14
+ pip-wheel-metadata/
15
+
16
+ # Virtual environments
17
+ .venv/
18
+ venv/
19
+ env/
20
+ ENV/
21
+ .env
22
+
23
+ # Distribution / packaging
24
+ sdist/
25
+ var/
26
+ lib/
27
+ lib64/
28
+
29
+ # Unit test / coverage
30
+ .tox/
31
+ .coverage
32
+ .coverage.*
33
+ .cache
34
+ .pytest_cache/
35
+ htmlcov/
36
+ nosetests.xml
37
+ coverage.xml
38
+ *.cover
39
+
40
+ # IDE
41
+ .vscode/
42
+ .idea/
43
+ *.swp
44
+ *.swo
45
+ *~
46
+
47
+ # macOS
48
+ .DS_Store
49
+ .AppleDouble
50
+ .LSOverride
51
+
52
+ # Windows
53
+ Thumbs.db
54
+ ehthumbs.db
55
+ Desktop.ini
56
+
57
+ # Gradio
58
+ gradio_cached_examples/
59
+ flagged/
60
+
61
+ # HuggingFace Spaces
62
+ .space_history/
63
+
64
+ # Claude Code
65
+ CLAUDE.md
66
+
67
+ # Output artefacts
68
+ output/
69
+ *.png
70
+ *.jpg
71
+ *.jpeg
72
+ *.gif
73
+ *.webp
README.md ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <img src="banner.svg" alt="Random Image as a Service" width="100%">
2
+
3
+ Point an `<img>` tag at it and get a gradient. Different every time, or pinned with a seed.
4
+
5
+ Nine styles: linear fades, radial blends, multi-stop color fields, freeform blobs, glassmorphism, banded ramps, low-poly geometry, and emoji — single or tiled in a hexagonal honeycomb.
6
+
7
+ ---
8
+
9
+ ## HTML
10
+
11
+ ```html
12
+ <img src="https://aakkaasshh-random-image-as-a-service.hf.space/image" alt="">
13
+ ```
14
+
15
+ That's the whole thing. Add parameters to control what you get:
16
+
17
+ > **Hugging Face free-tier Spaces go to sleep after 48 h of inactivity.** Any HTTP request wakes the Space, but HF's proxy handles the first ~30–60 s of boot itself — your `<img>` gets an HTML "waking up" page instead of a PNG. Use the self-waking embed below to handle this automatically.
18
+
19
+ ```html
20
+ <!-- pick a size -->
21
+ <img src="https://aakkaasshh-random-image-as-a-service.hf.space/image?width=1200&height=630">
22
+
23
+ <!-- pick a style -->
24
+ <img src="https://aakkaasshh-random-image-as-a-service.hf.space/image?style=geometric">
25
+
26
+ <!-- pin it — same seed always returns the same image -->
27
+ <img src="https://aakkaasshh-random-image-as-a-service.hf.space/image?style=emoji&seed=42">
28
+ ```
29
+
30
+ As a CSS background:
31
+
32
+ ```css
33
+ .hero {
34
+ background-image: url("https://aakkaasshh-random-image-as-a-service.hf.space/image?width=1920&height=1080&style=shape_blur");
35
+ background-size: cover;
36
+ }
37
+ ```
38
+
39
+ In Markdown:
40
+
41
+ ```markdown
42
+ ![](https://aakkaasshh-random-image-as-a-service.hf.space/image?width=800&height=200&style=gradient_ramp)
43
+ ```
44
+
45
+ As an OG image:
46
+
47
+ ```html
48
+ <meta property="og:image" content="https://aakkaasshh-random-image-as-a-service.hf.space/image?width=1200&height=630&seed=7">
49
+ ```
50
+
51
+ ### Self-waking embed
52
+
53
+ If the Space might be sleeping, use this instead of a bare `<img>`:
54
+
55
+ ```html
56
+ <img src="https://aakkaasshh-random-image-as-a-service.hf.space/image"
57
+ onerror="var e=this;setTimeout(function(){e.src=e.dataset.src+'?t='+Date.now()},10000)"
58
+ data-src="https://aakkaasshh-random-image-as-a-service.hf.space/image"
59
+ alt="">
60
+ ```
61
+
62
+ How it works:
63
+ - `onerror` fires when the browser gets HF's HTML wake-page instead of a PNG
64
+ - After 10 s it retries with a cache-busting `?t=` param
65
+ - Keeps retrying every 10 s until the Space is awake and returns a real image
66
+ - Once the Space is up the image loads and `onerror` never fires again
67
+
68
+ To never hit the sleep state at all, point an uptime monitor (UptimeRobot free tier) at `https://aakkaasshh-random-image-as-a-service.hf.space/image` every 5 minutes. Any hit counts as activity to HF, so the Space never goes idle.
69
+
70
+ ---
71
+
72
+ ## API
73
+
74
+ `GET /image` returns a PNG. All parameters are optional.
75
+
76
+ | Parameter | Default | Allowed values |
77
+ |---|---|---|
78
+ | `width` | 1024 | 64 – 4096 |
79
+ | `height` | 768 | 64 – 4096 |
80
+ | `style` | random | `linear` `radial` `multicolor` `freeform` `shape_blur` `gradient_ramp` `geometric` `emoji` `emoji_hex` |
81
+ | `seed` | random | any integer |
82
+
83
+ Bad dimensions return HTTP 400 with a JSON error body. Everything else is a PNG byte stream.
84
+
85
+ ### Speed
86
+
87
+ When hosted on Hugging Face Spaces (or run locally via `python app.py`), the Gradio server pre-generates a pool of 50 images at startup. Bare requests with no parameters are served from that pool — **~1ms turnaround**, no generation overhead:
88
+
89
+ ```
90
+ GET /image → pool hit (X-RIaaS-Source: pool) ~1ms
91
+ GET /image?style=linear → generated (X-RIaaS-Source: live) ~50–200ms
92
+ ```
93
+
94
+ The pool builds in the background; during the first ~15 seconds the server falls through to live generation automatically.
95
+
96
+ ### curl
97
+
98
+ ```bash
99
+ # save one
100
+ curl "https://aakkaasshh-random-image-as-a-service.hf.space/image?style=geometric&width=800&height=600" -o banner.png
101
+
102
+ # batch — 20 different seeds
103
+ for i in $(seq 1 20); do
104
+ curl "https://aakkaasshh-random-image-as-a-service.hf.space/image?seed=$i" -o "img_$i.png"
105
+ done
106
+ ```
107
+
108
+ ### Python
109
+
110
+ ```python
111
+ import io, urllib.request
112
+ from PIL import Image
113
+
114
+ with urllib.request.urlopen("https://aakkaasshh-random-image-as-a-service.hf.space/image?style=emoji_hex&seed=9") as r:
115
+ img = Image.open(io.BytesIO(r.read()))
116
+
117
+ img.save("out.png")
118
+ ```
119
+
120
+ Interactive docs at `/docs` (Swagger) and `/redoc`.
121
+
122
+ ---
123
+
124
+ ## Gradio
125
+
126
+ The Gradio UI is at `https://aakkaasshh-random-image-as-a-service.hf.space/` (or `http://localhost:7860` locally). Sliders for width and height, a style dropdown, a seed box, and a live preview.
127
+
128
+ Gradio also exposes its own inference API on the same server:
129
+
130
+ ```bash
131
+ curl -X POST "https://aakkaasshh-random-image-as-a-service.hf.space/run/generate_image" \
132
+ -H "Content-Type: application/json" \
133
+ -d '{"data": [800, 600, "geometric", "42"]}'
134
+ ```
135
+
136
+ The four values in `data` are width, height, style, and seed. Pass `""` for a random seed.
137
+
138
+ Or use `gradio_client`:
139
+
140
+ ```python
141
+ from gradio_client import Client
142
+
143
+ client = Client("https://aakkaasshh-random-image-as-a-service.hf.space")
144
+ image_path, info = client.predict(
145
+ 800, 600, "emoji_hex", "",
146
+ api_name="/generate_image",
147
+ )
148
+ # image_path is a local temp file
149
+ # info is "Style: emoji_hex | Seed: 1837492"
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Running locally
155
+
156
+ ```bash
157
+ git clone https://github.com/AkashSCIENTIST/Random-Image-as-a-Service
158
+ cd Random-Image-as-a-Service
159
+ pip install -r requirements.txt
160
+ ```
161
+
162
+ **Gradio + API server** (one command gives you both the UI and `GET /image`):
163
+
164
+ ```bash
165
+ python app.py
166
+ # UI → http://localhost:7860
167
+ # API → http://localhost:7860/image
168
+ ```
169
+
170
+ Or with uvicorn for production:
171
+
172
+ ```bash
173
+ uvicorn app:app --host 0.0.0.0 --port 7860
174
+ ```
175
+
176
+ **FastAPI-only server** (lighter, no UI):
177
+
178
+ ```bash
179
+ uvicorn api:app --reload
180
+ # http://localhost:8000/image
181
+ ```
182
+
183
+ **Without a server** — call the generator directly:
184
+
185
+ ```python
186
+ from core.service import generate
187
+
188
+ img, meta = generate(width=1024, height=768, style="emoji_hex", seed=42)
189
+ img.save("out.png")
190
+ print(meta)
191
+ # {'style': 'emoji_hex', 'width': 1024, 'height': 768, 'seed': 42, 'params': {...}}
192
+ ```
193
+
194
+ **Deploy to Hugging Face Spaces** — create a Space with SDK: Gradio, then:
195
+
196
+ ```bash
197
+ git remote add space https://huggingface.co/spaces/aakkaasshh/Random-Image-as-a-Service
198
+ git push space main
199
+ ```
200
+
201
+ Spaces detects the FastAPI `app` object in `app.py` and serves it with uvicorn. You get the Gradio UI at `/` and `GET /image` on the same URL — pool-backed, ~1ms after warmup.
202
+
203
+ ---
204
+
205
+ ## Styles
206
+
207
+ | Name | Description |
208
+ |---|---|
209
+ | `linear` | Two colors fading at any angle |
210
+ | `radial` | Circular fade from center |
211
+ | `multicolor` | 3–5 color stops, linear or radial |
212
+ | `freeform` | Two colors blended through scattered control points |
213
+ | `shape_blur` | Blurred translucent blobs on a light base |
214
+ | `gradient_ramp` | Gradient cut into hard color bands |
215
+ | `geometric` | Low-poly triangles in a coordinated palette |
216
+ | `emoji` | Single emoji on a plain background with a soft oval shadow |
217
+ | `emoji_hex` | Two emojis tiled on a honeycomb lattice — each surrounded entirely by the other |
218
+
219
+ ---
220
+
221
+ ## Adding a style
222
+
223
+ 1. Add `styles/my_style.py` — extend `GradientStyle`, implement `render()`, add `random_params()`.
224
+ 2. Register it in `core/registry.py`.
225
+ 3. Add the name to `VALID_STYLES` in `core/config.py`.
226
+
227
+ It shows up in the API, the Gradio dropdown, and `generate()` automatically.
api.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ from fastapi import FastAPI, Query, Response, HTTPException
3
+ from core import service, config
4
+
5
+ app = FastAPI(title="RIaaS — Random Image as a Service")
6
+
7
+
8
+ @app.get("/image")
9
+ def get_image(
10
+ width: int = Query(config.DEFAULT_WIDTH, ge=config.MIN_WIDTH, le=config.MAX_WIDTH),
11
+ height: int = Query(config.DEFAULT_HEIGHT, ge=config.MIN_HEIGHT, le=config.MAX_HEIGHT),
12
+ style: str = Query(None),
13
+ seed: int = Query(None),
14
+ ) -> Response:
15
+ try:
16
+ image, meta = service.generate(width, height, style, seed)
17
+ except ValueError as exc:
18
+ raise HTTPException(status_code=400, detail=str(exc))
19
+
20
+ buf = io.BytesIO()
21
+ image.save(buf, format="PNG")
22
+ buf.seek(0)
23
+ return Response(content=buf.read(), media_type="image/png")
app.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import random
3
+ import threading
4
+
5
+ import gradio as gr
6
+ from fastapi import FastAPI, HTTPException, Query
7
+ from fastapi.responses import Response
8
+ from PIL import Image
9
+
10
+ from core import config, service
11
+
12
+ # ── Pre-generated image pool ──────────────────────────────────────────────────
13
+ # Images are rendered once at startup and kept as PNG bytes in memory.
14
+ # GET /image with no params picks one at random: memory lookup + HTTP write ≈ 1ms.
15
+ POOL_SIZE = 50
16
+ _pool: list[bytes] = []
17
+
18
+
19
+ def _build_pool() -> None:
20
+ # Append one-by-one so the pool is usable after the very first image (~200ms),
21
+ # not only after all 50 are done. Critical for cold-start / post-sleep behaviour.
22
+ for _ in range(POOL_SIZE):
23
+ img, _ = service.generate(config.DEFAULT_WIDTH, config.DEFAULT_HEIGHT, None, None)
24
+ buf = io.BytesIO()
25
+ img.save(buf, "PNG", compress_level=1)
26
+ _pool.append(buf.getvalue())
27
+
28
+
29
+ threading.Thread(target=_build_pool, daemon=True).start()
30
+
31
+
32
+ # ── FastAPI app ───────────────────────────────────────────────────────────────
33
+ app = FastAPI(title="RIaaS — Random Image as a Service")
34
+
35
+
36
+ @app.get("/image")
37
+ async def image_api(
38
+ width: int | None = Query(None, ge=config.MIN_WIDTH, le=config.MAX_WIDTH),
39
+ height: int | None = Query(None, ge=config.MIN_HEIGHT, le=config.MAX_HEIGHT),
40
+ style: str | None = Query(None),
41
+ seed: int | None = Query(None),
42
+ ):
43
+ # Default request (no params) → serve from pool; ~1ms once pool is warm.
44
+ # While pool is still building the first time, fall through to live generation.
45
+ if _pool and width is None and height is None and style is None and seed is None:
46
+ return Response(
47
+ content=random.choice(_pool),
48
+ media_type="image/png",
49
+ headers={"X-RIaaS-Source": "pool"},
50
+ )
51
+
52
+ # Custom params → generate on demand
53
+ try:
54
+ img, _ = service.generate(
55
+ width or config.DEFAULT_WIDTH,
56
+ height or config.DEFAULT_HEIGHT,
57
+ style or "random",
58
+ seed,
59
+ )
60
+ except ValueError as exc:
61
+ raise HTTPException(status_code=400, detail=str(exc))
62
+
63
+ buf = io.BytesIO()
64
+ img.save(buf, "PNG", compress_level=1)
65
+ return Response(
66
+ content=buf.getvalue(),
67
+ media_type="image/png",
68
+ headers={"X-RIaaS-Source": "live"},
69
+ )
70
+
71
+
72
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
73
+ STYLE_CHOICES = ["random"] + config.VALID_STYLES
74
+
75
+
76
+ def generate_image(width: int, height: int, style: str, seed: str) -> tuple[Image.Image, str]:
77
+ parsed_seed = int(seed) if seed.strip() else None
78
+ try:
79
+ image, meta = service.generate(int(width), int(height), style, parsed_seed)
80
+ except ValueError as exc:
81
+ return None, str(exc)
82
+ return image, f"Style: {meta['style']} | Seed: {meta['seed']}"
83
+
84
+
85
+ with gr.Blocks(title="RIaaS — Random Image as a Service") as demo:
86
+ gr.Markdown("# Random Image as a Service\nGenerate beautiful gradient images on demand.")
87
+ with gr.Row():
88
+ with gr.Column(scale=1):
89
+ width_slider = gr.Slider(config.MIN_WIDTH, config.MAX_WIDTH, value=config.DEFAULT_WIDTH, step=8, label="Width")
90
+ height_slider = gr.Slider(config.MIN_HEIGHT, config.MAX_HEIGHT, value=config.DEFAULT_HEIGHT, step=8, label="Height")
91
+ style_dropdown = gr.Dropdown(STYLE_CHOICES, value="random", label="Style")
92
+ seed_input = gr.Textbox(value="", placeholder="Leave blank for random", label="Seed")
93
+ generate_btn = gr.Button("Generate", variant="primary")
94
+ with gr.Column(scale=2):
95
+ output_image = gr.Image(label="Result", type="pil")
96
+ output_info = gr.Textbox(label="Info", interactive=False)
97
+
98
+ generate_btn.click(
99
+ generate_image,
100
+ inputs=[width_slider, height_slider, style_dropdown, seed_input],
101
+ outputs=[output_image, output_info],
102
+ )
103
+
104
+ # Mount Gradio at root. Routes defined on `app` above (/image) are matched
105
+ # before the wildcard Gradio mount, so both coexist without conflict.
106
+ app = gr.mount_gradio_app(app, demo, path="/")
107
+
108
+ if __name__ == "__main__":
109
+ import uvicorn
110
+ uvicorn.run(app, host="0.0.0.0", port=7860)
banner.svg ADDED
core/__init__.py ADDED
File without changes
core/base.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from PIL import Image
3
+
4
+
5
+ class GradientStyle(ABC):
6
+ def __init__(self, width: int, height: int):
7
+ self.width = width
8
+ self.height = height
9
+
10
+ @abstractmethod
11
+ def render(self) -> Image.Image:
12
+ ...
core/color_utils.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import colorsys
3
+
4
+
5
+ def random_hex_color(rng: random.Random) -> str:
6
+ r, g, b = (rng.randint(0, 255) for _ in range(3))
7
+ return f"#{r:02x}{g:02x}{b:02x}"
8
+
9
+
10
+ def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
11
+ h = hex_color.lstrip("#")
12
+ return tuple(int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4))
13
+
14
+
15
+ def _rgb_to_hex(r: float, g: float, b: float) -> str:
16
+ return "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
17
+
18
+
19
+ def complementary(hex_color: str) -> str:
20
+ r, g, b = _hex_to_rgb(hex_color)
21
+ h, s, v = colorsys.rgb_to_hsv(r, g, b)
22
+ h = (h + 0.5) % 1.0
23
+ return _rgb_to_hex(*colorsys.hsv_to_rgb(h, max(s, 0.65), max(v, 0.75)))
24
+
25
+
26
+ def analogous(hex_color: str, n: int = 2, spread_deg: float = 30.0) -> list[str]:
27
+ r, g, b = _hex_to_rgb(hex_color)
28
+ h, s, v = colorsys.rgb_to_hsv(r, g, b)
29
+ spread = spread_deg / 360.0
30
+ step = spread / max(n - 1, 1)
31
+ start = h - spread / 2
32
+ return [_rgb_to_hex(*colorsys.hsv_to_rgb((start + i * step) % 1.0, s, v)) for i in range(n)]
33
+
34
+
35
+ def vibrant_analogous(rng: random.Random, n: int, spread_deg: float = 50.0) -> list[str]:
36
+ """Analogous palette with forced vibrancy and alternating lightness for differentiability."""
37
+ base_h = rng.random()
38
+ base_s = rng.uniform(0.60, 0.88)
39
+ base_v = rng.uniform(0.72, 0.94)
40
+ spread = spread_deg / 360.0
41
+ step = spread / max(n - 1, 1)
42
+ start = base_h - spread / 2
43
+ colors = []
44
+ for i in range(n):
45
+ hue = (start + i * step) % 1.0
46
+ # Alternate value high/low so adjacent colors are clearly distinct
47
+ v = base_v - 0.18 if i % 2 == 1 else base_v
48
+ s = min(0.95, base_s + 0.08) if i % 2 == 1 else base_s
49
+ colors.append(_rgb_to_hex(*colorsys.hsv_to_rgb(hue, s, max(0.55, v))))
50
+ return colors
51
+
52
+
53
+ def random_palette(rng: random.Random, n: int, scheme: str = "random") -> list[str]:
54
+ base = random_hex_color(rng)
55
+ if scheme == "complementary":
56
+ r, g, b = _hex_to_rgb(base)
57
+ h, s, v = colorsys.rgb_to_hsv(r, g, b)
58
+ # Force both colors to be vibrant
59
+ base = _rgb_to_hex(*colorsys.hsv_to_rgb(h, max(s, 0.65), max(v, 0.72)))
60
+ return [base, complementary(base)][:n]
61
+ if scheme == "analogous":
62
+ return vibrant_analogous(rng, n, spread_deg=rng.uniform(30, 60))
63
+ return [random_hex_color(rng) for _ in range(n)]
core/config.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIN_WIDTH = 64
2
+ MAX_WIDTH = 4096
3
+ MIN_HEIGHT = 64
4
+ MAX_HEIGHT = 4096
5
+
6
+ DEFAULT_WIDTH = 1024
7
+ DEFAULT_HEIGHT = 768
8
+ DEFAULT_SEED = None
9
+
10
+ VALID_STYLES = [
11
+ "linear",
12
+ "radial",
13
+ "multicolor",
14
+ "freeform",
15
+ "shape_blur",
16
+ "gradient_ramp",
17
+ "geometric",
18
+ "emoji",
19
+ "emoji_hex",
20
+ ]
core/registry.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from styles import linear, radial, multicolor, freeform, shape_blur, gradient_ramp, geometric, emoji, emoji_hex
3
+
4
+ STYLE_TABLE: dict[str, dict] = {
5
+ "linear": {"cls": linear.LinearGradient, "params": linear.random_params},
6
+ "radial": {"cls": radial.RadialGradient, "params": radial.random_params},
7
+ "multicolor": {"cls": multicolor.MulticolorGradient, "params": multicolor.random_params},
8
+ "freeform": {"cls": freeform.FreeformGradient, "params": freeform.random_params},
9
+ "shape_blur": {"cls": shape_blur.ShapeBlurGradient, "params": shape_blur.random_params},
10
+ "gradient_ramp": {"cls": gradient_ramp.GradientRampStyle, "params": gradient_ramp.random_params},
11
+ "geometric": {"cls": geometric.GeometricStyle, "params": geometric.random_params},
12
+ "emoji": {"cls": emoji.EmojiStyle, "params": emoji.random_params},
13
+ "emoji_hex": {"cls": emoji_hex.EmojiHexStyle, "params": emoji_hex.random_params},
14
+ }
15
+
16
+
17
+ def pick_random_style(
18
+ width: int,
19
+ height: int,
20
+ style_name: str = None,
21
+ seed: int = None,
22
+ ) -> tuple:
23
+ rng = random.Random(seed)
24
+ if style_name is None or style_name not in STYLE_TABLE:
25
+ style_name = rng.choice(list(STYLE_TABLE.keys()))
26
+
27
+ entry = STYLE_TABLE[style_name]
28
+ params = entry["params"](width, height, rng)
29
+ instance = entry["cls"](width=width, height=height, **params)
30
+ return instance, style_name, params
core/service.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image
2
+ from . import config, registry
3
+
4
+
5
+ def generate(
6
+ width: int,
7
+ height: int,
8
+ style: str = None,
9
+ seed: int = None,
10
+ ) -> tuple[Image.Image, dict]:
11
+ if not (config.MIN_WIDTH <= width <= config.MAX_WIDTH):
12
+ raise ValueError(f"width must be between {config.MIN_WIDTH} and {config.MAX_WIDTH}")
13
+ if not (config.MIN_HEIGHT <= height <= config.MAX_HEIGHT):
14
+ raise ValueError(f"height must be between {config.MIN_HEIGHT} and {config.MAX_HEIGHT}")
15
+
16
+ style_arg = None if style in (None, "random") else style
17
+ instance, chosen_style, params = registry.pick_random_style(width, height, style_arg, seed)
18
+ image = instance.render()
19
+
20
+ meta = {"style": chosen_style, "width": width, "height": height, "seed": seed, "params": params}
21
+ return image, meta
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ pillow
2
+ numpy
3
+ fastapi
4
+ uvicorn
5
+ gradio
styles/__init__.py ADDED
File without changes
styles/duotone.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import colorsys
3
+ import numpy as np
4
+ from PIL import Image, ImageFilter
5
+ from core.base import GradientStyle
6
+
7
+
8
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
9
+ h = hex_color.lstrip("#")
10
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
11
+
12
+
13
+ def _hsv_to_hex(h: float, s: float, v: float) -> str:
14
+ r, g, b = colorsys.hsv_to_rgb(h, s, v)
15
+ return "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
16
+
17
+
18
+ class DuotoneGradient(GradientStyle):
19
+ def __init__(
20
+ self,
21
+ width: int,
22
+ height: int,
23
+ shadow_color: str,
24
+ highlight_color: str,
25
+ noise_seed: int = None,
26
+ ):
27
+ super().__init__(width, height)
28
+ self.shadow_color = shadow_color
29
+ self.highlight_color = highlight_color
30
+ self.noise_seed = noise_seed
31
+
32
+ def render(self) -> Image.Image:
33
+ rng = np.random.default_rng(self.noise_seed)
34
+
35
+ # Multi-scale noise gives a more organic, interesting luminance base
36
+ lum = np.zeros((self.height, self.width), dtype=np.float32)
37
+ for scale in [4, 8, 16]:
38
+ th, tw = max(2, self.height // scale), max(2, self.width // scale)
39
+ layer = rng.random((th, tw)).astype(np.float32)
40
+ layer_img = Image.fromarray((layer * 255).astype(np.uint8), "L")
41
+ layer_img = layer_img.resize((self.width, self.height), Image.BILINEAR)
42
+ layer_img = layer_img.filter(ImageFilter.GaussianBlur(radius=max(2, min(self.width, self.height) // (scale * 2))))
43
+ lum += np.array(layer_img, dtype=np.float32) / 255.0
44
+ lum /= 3.0
45
+ # Boost contrast so midtones don't dominate
46
+ lum = np.clip((lum - 0.5) * 1.5 + 0.5, 0, 1)
47
+
48
+ shadow = np.array(_hex_to_rgb(self.shadow_color), dtype=np.float32)
49
+ highlight = np.array(_hex_to_rgb(self.highlight_color), dtype=np.float32)
50
+ img_arr = (shadow * (1 - lum[..., None]) + highlight * lum[..., None]).astype(np.uint8)
51
+ return Image.fromarray(img_arr, "RGB")
52
+
53
+
54
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
55
+ # Shadow: very dark, richly saturated
56
+ h_shadow = rng.random()
57
+ shadow = _hsv_to_hex(h_shadow, rng.uniform(0.70, 0.90), rng.uniform(0.08, 0.20))
58
+ # Highlight: bright, vivid, complementary hue
59
+ h_highlight = (h_shadow + rng.uniform(0.40, 0.60)) % 1.0
60
+ highlight = _hsv_to_hex(h_highlight, rng.uniform(0.75, 0.95), rng.uniform(0.88, 1.00))
61
+ return {
62
+ "shadow_color": shadow,
63
+ "highlight_color": highlight,
64
+ "noise_seed": rng.randint(0, 2 ** 31),
65
+ }
styles/emoji.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import colorsys
3
+ from pathlib import Path
4
+ import numpy as np
5
+ from PIL import Image, ImageDraw, ImageFilter, ImageFont
6
+ from core.base import GradientStyle
7
+
8
+ EMOJI_POOL = [
9
+ # Nature & weather
10
+ "🌊", "🔥", "⚡", "🌸", "🌺", "🍀", "🌙", "✨", "🌈", "🍄",
11
+ "🌻", "🏔️", "🌿", "🌴", "🍁", "🌞", "🌵", "🌾", "🌷", "🌋",
12
+ "❄️", "🌏", "🏵️", "🌬️", "🌪️", "🌠", "☄️", "⛅", "🌑", "🌊",
13
+ # Animals
14
+ "🦋", "🐉", "🦄", "🦅", "🦊", "🦁", "🐯", "🐸", "🦩", "🐬",
15
+ "🦜", "🦀", "🐙", "🦚", "🪸", "🦭", "🦦", "🦥", "🦘", "🦒",
16
+ "🐘", "🦏", "🐊", "🦈", "🐋", "🦑", "🦩", "🦢", "🦋", "🐓",
17
+ # Objects — Accessories & Jewelry
18
+ "💎", "👑", "🎩", "🕶️", "💍", "🪬", "🧿",
19
+ # Objects — Music
20
+ "🎵", "🎶", "🎷", "🎸", "🎺", "🎻", "🥁", "🪗", "🪘", "🎹",
21
+ # Objects — Light & Optics
22
+ "📷", "🎬", "📽️", "🔭", "💡", "🔦", "🕯️", "🪔",
23
+ # Objects — Science
24
+ "🔬", "⚗️", "🧬", "🧪", "🔮",
25
+ # Objects — Tools
26
+ "⚙️", "🔧", "🔩", "⚒️", "🛠️", "🪛", "🧲",
27
+ # Objects — Games & Sport trophies
28
+ "🎯", "🎮", "🕹️", "🎲", "🎳", "🎱", "🏆", "🥇", "🎪",
29
+ "⚽", "🏀", "🎾", "🏈", "⚾", "🏓", "🥊", "🎿",
30
+ # Objects — Celebration & Misc
31
+ "🎨", "🎭", "🎁", "🎀", "🎊", "🎉", "🧨", "🪆", "🎠",
32
+ "🎋", "🪩", "🎆", "🎇", "🎑",
33
+ # Objects — Food (iconic shapes)
34
+ "🍉", "🎂", "🍕", "🍦", "🍩", "🍣", "🍭", "🧁",
35
+ # Objects — Vehicles & Transport (iconic shapes)
36
+ "🚀", "✈️", "🛸", "🚁", "⛵", "🏎️"
37
+ ]
38
+
39
+ _FONT_CANDIDATES = [
40
+ "C:/Windows/Fonts/seguiemj.ttf",
41
+ r"C:\Windows\Fonts\seguiemj.ttf",
42
+ "/System/Library/Fonts/Apple Color Emoji.ttc",
43
+ "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
44
+ "/usr/share/fonts/noto/NotoColorEmoji.ttf",
45
+ "/usr/share/fonts/google-noto-emoji/NotoColorEmoji.ttf",
46
+ ]
47
+
48
+
49
+ def _load_emoji_font(size: int) -> ImageFont.FreeTypeFont | None:
50
+ for path in _FONT_CANDIDATES:
51
+ if Path(path).exists():
52
+ try:
53
+ return ImageFont.truetype(path, size)
54
+ except Exception:
55
+ continue
56
+ return None
57
+
58
+
59
+ def _detect_is_light(emoji: str, font: ImageFont.FreeTypeFont) -> bool:
60
+ """Render emoji on black; bright result → emoji is light-coloured."""
61
+ probe = Image.new("RGB", (80, 80), (0, 0, 0))
62
+ d = ImageDraw.Draw(probe)
63
+ d.text((8, 8), emoji, font=font, embedded_color=True)
64
+ return np.array(probe, dtype=np.float32).mean() > 145
65
+
66
+
67
+ class EmojiStyle(GradientStyle):
68
+ def __init__(
69
+ self,
70
+ width: int,
71
+ height: int,
72
+ emoji: str,
73
+ bg_light: str,
74
+ bg_dark: str,
75
+ seed: int = None,
76
+ ):
77
+ super().__init__(width, height)
78
+ self.emoji = emoji
79
+ self.bg_light = bg_light
80
+ self.bg_dark = bg_dark
81
+ self.seed = seed
82
+
83
+ def _parse_hex(self, hex_color: str) -> tuple:
84
+ h = hex_color.lstrip("#")
85
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
86
+
87
+ def render(self) -> Image.Image:
88
+ font_size = int(min(self.width, self.height) * 0.52)
89
+ font = _load_emoji_font(font_size)
90
+
91
+ if font is None:
92
+ return Image.new("RGB", (self.width, self.height), self._parse_hex(self.bg_light))
93
+
94
+ probe_font = _load_emoji_font(64)
95
+ is_light = _detect_is_light(self.emoji, probe_font) if probe_font else False
96
+ bg = self._parse_hex(self.bg_dark if is_light else self.bg_light)
97
+
98
+ canvas = Image.new("RGBA", (self.width, self.height), (*bg, 255))
99
+
100
+ # Measure emoji to find centre position
101
+ m = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
102
+ bbox = m.textbbox((0, 0), self.emoji, font=font, embedded_color=True)
103
+ ew, eh = bbox[2] - bbox[0], bbox[3] - bbox[1]
104
+ # Shift emoji slightly above centre so the shadow shows below it
105
+ lift = int(min(self.width, self.height) * 0.025)
106
+ tx = (self.width - ew) // 2 - bbox[0]
107
+ ty = (self.height - eh) // 2 - bbox[1] - lift
108
+
109
+ # Shadow: a soft oval disc directly below the emoji — NOT shape-matched.
110
+ # This gives a "floating above the surface" look regardless of emoji shape.
111
+ shadow_r_x = int(min(self.width, self.height) * 0.30)
112
+ shadow_r_y = int(shadow_r_x * 0.30)
113
+ shadow_drop = int(min(self.width, self.height) * 0.06)
114
+ scx = self.width // 2
115
+ scy = self.height // 2 + lift + shadow_drop
116
+ shadow = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
117
+ ImageDraw.Draw(shadow).ellipse(
118
+ [scx - shadow_r_x, scy - shadow_r_y, scx + shadow_r_x, scy + shadow_r_y],
119
+ fill=(0, 0, 0, 90),
120
+ )
121
+ shadow = shadow.filter(ImageFilter.GaussianBlur(radius=shadow_r_x // 3))
122
+ canvas = Image.alpha_composite(canvas, shadow)
123
+
124
+ # Emoji
125
+ emoji_layer = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
126
+ ImageDraw.Draw(emoji_layer).text((tx, ty), self.emoji, font=font, embedded_color=True)
127
+ canvas = Image.alpha_composite(canvas, emoji_layer)
128
+
129
+ return canvas.convert("RGB")
130
+
131
+
132
+ def random_params(_width: int, _height: int, rng: random.Random) -> dict:
133
+ emoji = rng.choice(EMOJI_POOL)
134
+ h = rng.random()
135
+ r, g, b = colorsys.hsv_to_rgb(h, rng.uniform(0.06, 0.16), rng.uniform(0.93, 0.99))
136
+ bg_light = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
137
+ r2, g2, b2 = colorsys.hsv_to_rgb((h + 0.5) % 1.0, rng.uniform(0.55, 0.80), rng.uniform(0.08, 0.18))
138
+ bg_dark = "#{:02x}{:02x}{:02x}".format(int(r2 * 255), int(g2 * 255), int(b2 * 255))
139
+ return {
140
+ "emoji": emoji,
141
+ "bg_light": bg_light,
142
+ "bg_dark": bg_dark,
143
+ "seed": rng.randint(0, 2 ** 31),
144
+ }
styles/emoji_hex.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import random
3
+ import colorsys
4
+ from PIL import Image, ImageDraw
5
+ from core.base import GradientStyle
6
+ from styles.emoji import EMOJI_POOL, _load_emoji_font
7
+
8
+ _SQRT3 = math.sqrt(3)
9
+
10
+
11
+ class EmojiHexStyle(GradientStyle):
12
+ """
13
+ Emojis placed at the vertices of a honeycomb tiling.
14
+ The honeycomb vertex graph is bipartite: alternate corners of every hexagon
15
+ belong to sublattice A (emoji_0) and sublattice B (emoji_1), so adjacent
16
+ vertices are always opposite emojis — exactly the 'alternate corners' pattern.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ width: int,
22
+ height: int,
23
+ emojis: list[str], # exactly 2 entries
24
+ bg_color: str,
25
+ emoji_size: int,
26
+ seed: int = None,
27
+ ):
28
+ super().__init__(width, height)
29
+ self.emojis = emojis[:2]
30
+ self.bg_color = bg_color
31
+ self.emoji_size = emoji_size
32
+ self.seed = seed
33
+
34
+ def _parse_hex(self, hex_color: str) -> tuple:
35
+ h = hex_color.lstrip("#")
36
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
37
+
38
+ def render(self) -> Image.Image:
39
+ bg = self._parse_hex(self.bg_color)
40
+ img = Image.new("RGBA", (self.width, self.height), (*bg, 255))
41
+
42
+ font = _load_emoji_font(self.emoji_size)
43
+ if font is None:
44
+ return img.convert("RGB")
45
+
46
+ emo_a, emo_b = self.emojis[0], self.emojis[1]
47
+ m = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
48
+ ba = m.textbbox((0, 0), emo_a, font=font, embedded_color=True)
49
+ bb = m.textbbox((0, 0), emo_b, font=font, embedded_color=True)
50
+ # Pre-compute draw offset so each emoji is centred on its lattice point
51
+ oa = (-(ba[2] - ba[0]) // 2 - ba[0], -(ba[3] - ba[1]) // 2 - ba[1])
52
+ ob = (-(bb[2] - bb[0]) // 2 - bb[0], -(bb[3] - bb[1]) // 2 - bb[1])
53
+
54
+ layer = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
55
+ draw = ImageDraw.Draw(layer)
56
+
57
+ # R = circumradius of each hexagonal cell = nearest-neighbour distance
58
+ # in the honeycomb. Set > emoji_size so adjacent emojis don't overlap.
59
+ R = int(self.emoji_size * 1.30)
60
+
61
+ # Triangular lattice of hex *centres*:
62
+ # a1 = (3R/2, R√3/2) a2 = (0, R√3)
63
+ a1x, a1y = 1.5 * R, 0.5 * R * _SQRT3
64
+ a2x, a2y = 0.0, R * _SQRT3
65
+
66
+ # Vertex offsets from each hex centre.
67
+ # Even corners (k=0,2,4) → sublattice A (alternate corners)
68
+ # Odd corners (k=1,3,5) → sublattice B (the other alternate corners)
69
+ half = R * 0.5
70
+ h32 = R * _SQRT3 * 0.5
71
+ offsets_a = [(R, 0.0), (-half, h32), (-half, -h32)]
72
+ offsets_b = [(half, h32), (-R, 0.0), (half, -h32)]
73
+
74
+ margin = 3
75
+ pad = self.emoji_size
76
+ m_max = int(self.width / a1x) + margin + 2
77
+ n_max = int(self.height / a2y) + margin + 2
78
+
79
+ seen_a: set[tuple[int, int]] = set()
80
+ seen_b: set[tuple[int, int]] = set()
81
+
82
+ for mi in range(-margin, m_max):
83
+ for ni in range(-margin, n_max):
84
+ cx = mi * a1x + ni * a2x
85
+ cy = mi * a1y + ni * a2y
86
+
87
+ for offsets, seen, emo, off in (
88
+ (offsets_a, seen_a, emo_a, oa),
89
+ (offsets_b, seen_b, emo_b, ob),
90
+ ):
91
+ for dx, dy in offsets:
92
+ pos = (round(cx + dx), round(cy + dy))
93
+ if pos in seen:
94
+ continue
95
+ seen.add(pos)
96
+ px, py = pos
97
+ if -pad <= px <= self.width + pad and -pad <= py <= self.height + pad:
98
+ draw.text((px + off[0], py + off[1]), emo, font=font, embedded_color=True)
99
+
100
+ return Image.alpha_composite(img, layer).convert("RGB")
101
+
102
+
103
+ def random_params(_width: int, _height: int, rng: random.Random) -> dict:
104
+ emojis = rng.sample(EMOJI_POOL, 2)
105
+ h = rng.random()
106
+ r, g, b = colorsys.hsv_to_rgb(h, rng.uniform(0.04, 0.12), rng.uniform(0.93, 0.99))
107
+ bg_color = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
108
+ # emoji_size drives both font render size and cell spacing
109
+ emoji_size = max(48, min(_width, _height) // rng.randint(5, 8))
110
+ return {
111
+ "emojis": emojis,
112
+ "bg_color": bg_color,
113
+ "emoji_size": emoji_size,
114
+ "seed": rng.randint(0, 2 ** 31),
115
+ }
styles/freeform.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import numpy as np
3
+ from PIL import Image
4
+ from core.base import GradientStyle
5
+ from core import color_utils
6
+
7
+
8
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
9
+ h = hex_color.lstrip("#")
10
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
11
+
12
+
13
+ def _idw_blend(xs: np.ndarray, ys: np.ndarray, points: np.ndarray, colors: np.ndarray, power: float = 2.0) -> np.ndarray:
14
+ coords = np.stack([xs.ravel(), ys.ravel()], axis=-1).astype(np.float32)
15
+ diffs = coords[:, None, :] - points[None, :, :]
16
+ dist2 = (diffs ** 2).sum(axis=-1)
17
+ dist2 = np.maximum(dist2, 1e-10)
18
+ weights = 1.0 / dist2 ** (power / 2)
19
+ weights /= weights.sum(axis=-1, keepdims=True)
20
+ blended = (weights[:, :, None] * colors[None, :, :]).sum(axis=1)
21
+ return blended.reshape(*xs.shape, 3).astype(np.uint8)
22
+
23
+
24
+ class FreeformGradient(GradientStyle):
25
+ def __init__(self, width: int, height: int, colors: list[str], num_points: int = 5, seed: int = None):
26
+ super().__init__(width, height)
27
+ self.colors = colors
28
+ self.num_points = num_points
29
+ self.seed = seed
30
+
31
+ def render(self) -> Image.Image:
32
+ rng = np.random.default_rng(self.seed)
33
+ px = rng.uniform(0, self.width - 1, self.num_points)
34
+ py = rng.uniform(0, self.height - 1, self.num_points)
35
+ points = np.stack([px, py], axis=-1).astype(np.float32)
36
+
37
+ rgbs = [_hex_to_rgb(self.colors[i % len(self.colors)]) for i in range(self.num_points)]
38
+ colors_arr = np.array(rgbs, dtype=np.float32)
39
+
40
+ ys, xs = np.mgrid[0:self.height, 0:self.width]
41
+ img_arr = _idw_blend(xs, ys, points, colors_arr)
42
+ return Image.fromarray(img_arr, "RGB")
43
+
44
+
45
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
46
+ return {
47
+ "colors": color_utils.random_palette(rng, 2, scheme="analogous"),
48
+ "num_points": rng.randint(5, 9),
49
+ "seed": rng.randint(0, 2 ** 31),
50
+ }
styles/geometric.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import numpy as np
3
+ from PIL import Image, ImageDraw
4
+ from core.base import GradientStyle
5
+ from core import color_utils
6
+
7
+ SUPERSAMPLE = 2
8
+ PAD_FRAC = 0.20 # render 20% larger on each side, then crop
9
+
10
+
11
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
12
+ h = hex_color.lstrip("#")
13
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
14
+
15
+
16
+ def _avg_color(palette: list[str]) -> tuple[int, int, int]:
17
+ rgbs = [_hex_to_rgb(c) for c in palette]
18
+ return tuple(sum(ch) // len(ch) for ch in zip(*rgbs))
19
+
20
+
21
+ class GeometricStyle(GradientStyle):
22
+ def __init__(
23
+ self,
24
+ width: int,
25
+ height: int,
26
+ palette: list[str],
27
+ rows: int = 6,
28
+ cols: int = 6,
29
+ jitter: float = 0.3,
30
+ seed: int = None,
31
+ ):
32
+ super().__init__(width, height)
33
+ self.palette = palette
34
+ self.rows = rows
35
+ self.cols = cols
36
+ self.jitter = jitter
37
+ self.seed = seed
38
+
39
+ def render(self) -> Image.Image:
40
+ rng = random.Random(self.seed)
41
+
42
+ # Draw on a padded, 2× supersampled canvas then crop to final size
43
+ pad_w = int(self.width * PAD_FRAC)
44
+ pad_h = int(self.height * PAD_FRAC)
45
+ canvas_w = (self.width + 2 * pad_w) * SUPERSAMPLE
46
+ canvas_h = (self.height + 2 * pad_h) * SUPERSAMPLE
47
+
48
+ cell_w = canvas_w / self.cols
49
+ cell_h = canvas_h / self.rows
50
+
51
+ def jittered(x: float, y: float) -> tuple[float, float]:
52
+ return (
53
+ x + rng.uniform(-self.jitter, self.jitter) * cell_w,
54
+ y + rng.uniform(-self.jitter, self.jitter) * cell_h,
55
+ )
56
+
57
+ pts = [
58
+ [jittered(c * cell_w, r * cell_h) for c in range(self.cols + 1)]
59
+ for r in range(self.rows + 1)
60
+ ]
61
+
62
+ # Fill with blended average color so jitter gaps show a sensible color
63
+ img = Image.new("RGB", (canvas_w, canvas_h), _avg_color(self.palette))
64
+ draw = ImageDraw.Draw(img)
65
+
66
+ for r in range(self.rows):
67
+ for c in range(self.cols):
68
+ tl, tr = pts[r][c], pts[r][c + 1]
69
+ bl, br = pts[r + 1][c], pts[r + 1][c + 1]
70
+ draw.polygon([tl, tr, br], fill=_hex_to_rgb(rng.choice(self.palette)))
71
+ draw.polygon([tl, bl, br], fill=_hex_to_rgb(rng.choice(self.palette)))
72
+
73
+ # Crop to the non-padded region, then downsample
74
+ x0, y0 = pad_w * SUPERSAMPLE, pad_h * SUPERSAMPLE
75
+ img = img.crop((x0, y0, x0 + self.width * SUPERSAMPLE, y0 + self.height * SUPERSAMPLE))
76
+ return img.resize((self.width, self.height), Image.LANCZOS)
77
+
78
+
79
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
80
+ n = rng.randint(4, 7)
81
+ return {
82
+ "palette": color_utils.random_palette(rng, n, scheme="analogous"),
83
+ "rows": rng.randint(9, 18),
84
+ "cols": rng.randint(9, 18),
85
+ "jitter": rng.uniform(0.08, 0.22),
86
+ "seed": rng.randint(0, 2 ** 31),
87
+ }
styles/gradient_ramp.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import random
3
+ import numpy as np
4
+ from PIL import Image
5
+ from core.base import GradientStyle
6
+ from core import color_utils
7
+
8
+ SUPERSAMPLE = 3
9
+
10
+
11
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
12
+ h = hex_color.lstrip("#")
13
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
14
+
15
+
16
+ def _interpolate_stops(t: np.ndarray, colors: list[str]) -> np.ndarray:
17
+ n = len(colors)
18
+ rgbs = np.array([_hex_to_rgb(c) for c in colors], dtype=np.float32)
19
+ stops = np.linspace(0, 1, n)
20
+ out = np.zeros((*t.shape, 3), dtype=np.float32)
21
+ for i in range(n - 1):
22
+ mask = (t >= stops[i]) & (t <= stops[i + 1])
23
+ lt = (t[mask] - stops[i]) / (stops[i + 1] - stops[i])
24
+ out[mask] = rgbs[i] * (1 - lt[..., None]) + rgbs[i + 1] * lt[..., None]
25
+ return out.astype(np.uint8)
26
+
27
+
28
+ class GradientRampStyle(GradientStyle):
29
+ def __init__(self, width: int, height: int, colors: list[str], steps: int = 6, angle: float = 0.0):
30
+ super().__init__(width, height)
31
+ self.colors = colors
32
+ self.steps = steps
33
+ self.angle = angle
34
+
35
+ def render(self) -> Image.Image:
36
+ W, H = self.width * SUPERSAMPLE, self.height * SUPERSAMPLE
37
+ rad = math.radians(self.angle)
38
+ cos_a, sin_a = math.cos(rad), math.sin(rad)
39
+ ys, xs = np.mgrid[0:H, 0:W]
40
+ cx, cy = W / 2, H / 2
41
+ proj = (xs - cx) * cos_a + (ys - cy) * sin_a
42
+ proj -= proj.min()
43
+ denom = proj.max()
44
+ t_cont = proj / denom if denom != 0 else proj
45
+
46
+ t_quantized = np.clip(np.floor(t_cont * self.steps) / self.steps, 0, 1)
47
+ img_arr = _interpolate_stops(t_quantized.astype(np.float32), self.colors)
48
+ big = Image.fromarray(img_arr, "RGB")
49
+ return big.resize((self.width, self.height), Image.LANCZOS)
50
+
51
+
52
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
53
+ n = rng.randint(2, 4)
54
+ return {
55
+ "colors": color_utils.random_palette(rng, n, scheme="analogous"),
56
+ "steps": rng.randint(4, 10),
57
+ "angle": rng.uniform(0, 360),
58
+ }
styles/linear.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import random
3
+ import numpy as np
4
+ from PIL import Image
5
+ from core.base import GradientStyle
6
+ from core import color_utils
7
+
8
+
9
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
10
+ h = hex_color.lstrip("#")
11
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
12
+
13
+
14
+ def _to_linear(v: np.ndarray) -> np.ndarray:
15
+ """sRGB → linear light (gamma decode)."""
16
+ v = v / 255.0
17
+ return np.where(v <= 0.04045, v / 12.92, ((v + 0.055) / 1.055) ** 2.4)
18
+
19
+
20
+ def _to_srgb(v: np.ndarray) -> np.ndarray:
21
+ """Linear light → sRGB (gamma encode)."""
22
+ return np.where(v <= 0.0031308, v * 12.92, 1.055 * v ** (1.0 / 2.4) - 0.055)
23
+
24
+
25
+ class LinearGradient(GradientStyle):
26
+ def __init__(self, width: int, height: int, colors: list[str], angle: float = 0.0):
27
+ super().__init__(width, height)
28
+ self.colors = colors
29
+ self.angle = angle
30
+
31
+ def render(self) -> Image.Image:
32
+ rad = math.radians(self.angle)
33
+ cos_a, sin_a = math.cos(rad), math.sin(rad)
34
+
35
+ ys, xs = np.mgrid[0:self.height, 0:self.width]
36
+ cx, cy = self.width / 2, self.height / 2
37
+ proj = (xs - cx) * cos_a + (ys - cy) * sin_a
38
+ proj -= proj.min()
39
+ denom = proj.max()
40
+ t = (proj / denom if denom != 0 else proj).astype(np.float32)
41
+
42
+ c0 = _to_linear(np.array(_hex_to_rgb(self.colors[0]), dtype=np.float32))
43
+ c1 = _to_linear(np.array(_hex_to_rgb(self.colors[1]), dtype=np.float32))
44
+ blended_lin = c0 * (1 - t[..., None]) + c1 * t[..., None]
45
+ img_arr = np.clip(_to_srgb(blended_lin) * 255, 0, 255).astype(np.uint8)
46
+ return Image.fromarray(img_arr, "RGB")
47
+
48
+
49
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
50
+ return {
51
+ "colors": color_utils.random_palette(rng, 2, scheme="complementary"),
52
+ "angle": rng.uniform(0, 360),
53
+ }
styles/mesh.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import numpy as np
3
+ from PIL import Image
4
+ from core.base import GradientStyle
5
+ from core import color_utils
6
+
7
+
8
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
9
+ h = hex_color.lstrip("#")
10
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
11
+
12
+
13
+ def _idw_blend(xs: np.ndarray, ys: np.ndarray, points: np.ndarray, colors: np.ndarray, power: float = 3.0) -> np.ndarray:
14
+ coords = np.stack([xs.ravel(), ys.ravel()], axis=-1).astype(np.float32)
15
+ diffs = coords[:, None, :] - points[None, :, :]
16
+ dist2 = (diffs ** 2).sum(axis=-1)
17
+ dist2 = np.maximum(dist2, 1e-10)
18
+ weights = 1.0 / dist2 ** (power / 2)
19
+ weights /= weights.sum(axis=-1, keepdims=True)
20
+ blended = (weights[:, :, None] * colors[None, :, :]).sum(axis=1)
21
+ return blended.reshape(*xs.shape, 3).astype(np.uint8)
22
+
23
+
24
+ class MeshGradient(GradientStyle):
25
+ def __init__(self, width: int, height: int, colors: list[str], grid: tuple[int, int] = (3, 3)):
26
+ super().__init__(width, height)
27
+ self.colors = colors
28
+ self.grid = grid
29
+
30
+ def render(self) -> Image.Image:
31
+ rows, cols = self.grid
32
+ n_points = rows * cols
33
+ gx = np.linspace(0, self.width - 1, cols)
34
+ gy = np.linspace(0, self.height - 1, rows)
35
+ gxx, gyy = np.meshgrid(gx, gy)
36
+ points = np.stack([gxx.ravel(), gyy.ravel()], axis=-1)
37
+
38
+ rgbs = [_hex_to_rgb(self.colors[i % len(self.colors)]) for i in range(n_points)]
39
+ colors_arr = np.array(rgbs, dtype=np.float32)
40
+
41
+ ys, xs = np.mgrid[0:self.height, 0:self.width]
42
+ img_arr = _idw_blend(xs, ys, points, colors_arr)
43
+ return Image.fromarray(img_arr, "RGB")
44
+
45
+
46
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
47
+ rows = rng.randint(2, 5)
48
+ cols = rng.randint(2, 5)
49
+ n = rows * cols
50
+ return {
51
+ "colors": color_utils.random_palette(rng, n, scheme="analogous"),
52
+ "grid": (rows, cols),
53
+ }
styles/multicolor.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import random
3
+ import numpy as np
4
+ from PIL import Image
5
+ from core.base import GradientStyle
6
+ from core import color_utils
7
+
8
+
9
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
10
+ h = hex_color.lstrip("#")
11
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
12
+
13
+
14
+ def _to_linear(v: np.ndarray) -> np.ndarray:
15
+ v = v / 255.0
16
+ return np.where(v <= 0.04045, v / 12.92, ((v + 0.055) / 1.055) ** 2.4)
17
+
18
+
19
+ def _to_srgb(v: np.ndarray) -> np.ndarray:
20
+ return np.where(v <= 0.0031308, v * 12.92, 1.055 * v ** (1.0 / 2.4) - 0.055)
21
+
22
+
23
+ def _interpolate_stops_linear_light(t: np.ndarray, colors: list[str]) -> np.ndarray:
24
+ n = len(colors)
25
+ rgbs_lin = np.array([_to_linear(np.array(_hex_to_rgb(c), dtype=np.float32)) for c in colors])
26
+ stops = np.linspace(0, 1, n)
27
+ out = np.zeros((*t.shape, 3), dtype=np.float32)
28
+ for i in range(n - 1):
29
+ mask = (t >= stops[i]) & (t <= stops[i + 1])
30
+ lt = (t[mask] - stops[i]) / (stops[i + 1] - stops[i])
31
+ out[mask] = rgbs_lin[i] * (1 - lt[..., None]) + rgbs_lin[i + 1] * lt[..., None]
32
+ return np.clip(_to_srgb(out) * 255, 0, 255).astype(np.uint8)
33
+
34
+
35
+ class MulticolorGradient(GradientStyle):
36
+ def __init__(self, width: int, height: int, colors: list[str], angle: float = 0.0, mode: str = "linear"):
37
+ super().__init__(width, height)
38
+ self.colors = colors
39
+ self.angle = angle
40
+ self.mode = mode
41
+
42
+ def render(self) -> Image.Image:
43
+ ys, xs = np.mgrid[0:self.height, 0:self.width]
44
+ if self.mode == "radial":
45
+ cx, cy = self.width / 2, self.height / 2
46
+ dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
47
+ r = max(self.width, self.height) * 0.72
48
+ t = np.clip(dist / r, 0, 1).astype(np.float32)
49
+ else:
50
+ rad = math.radians(self.angle)
51
+ cos_a, sin_a = math.cos(rad), math.sin(rad)
52
+ cx, cy = self.width / 2, self.height / 2
53
+ proj = (xs - cx) * cos_a + (ys - cy) * sin_a
54
+ proj -= proj.min()
55
+ denom = proj.max()
56
+ t = (proj / denom if denom != 0 else proj).astype(np.float32)
57
+
58
+ return Image.fromarray(_interpolate_stops_linear_light(t, self.colors), "RGB")
59
+
60
+
61
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
62
+ mode = rng.choice(["linear", "radial"])
63
+ n = 2 if mode == "radial" else rng.randint(3, 5)
64
+ return {
65
+ "colors": color_utils.random_palette(rng, n, scheme="analogous"),
66
+ "angle": rng.uniform(0, 360),
67
+ "mode": mode,
68
+ }
styles/radial.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import numpy as np
3
+ from PIL import Image
4
+ from core.base import GradientStyle
5
+ from core import color_utils
6
+
7
+
8
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
9
+ h = hex_color.lstrip("#")
10
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
11
+
12
+
13
+ def _to_linear(v: np.ndarray) -> np.ndarray:
14
+ v = v / 255.0
15
+ return np.where(v <= 0.04045, v / 12.92, ((v + 0.055) / 1.055) ** 2.4)
16
+
17
+
18
+ def _to_srgb(v: np.ndarray) -> np.ndarray:
19
+ return np.where(v <= 0.0031308, v * 12.92, 1.055 * v ** (1.0 / 2.4) - 0.055)
20
+
21
+
22
+ class RadialGradient(GradientStyle):
23
+ def __init__(
24
+ self,
25
+ width: int,
26
+ height: int,
27
+ colors: list[str],
28
+ center: tuple[float, float] = (0.5, 0.5),
29
+ radius: float = None,
30
+ ):
31
+ super().__init__(width, height)
32
+ self.colors = colors
33
+ self.center = center
34
+ self.radius = radius if radius is not None else max(width, height) * 0.7
35
+
36
+ def render(self) -> Image.Image:
37
+ cx = self.center[0] * self.width
38
+ cy = self.center[1] * self.height
39
+ ys, xs = np.mgrid[0:self.height, 0:self.width]
40
+ dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
41
+ t = np.clip(dist / self.radius, 0, 1).astype(np.float32)
42
+
43
+ c0 = _to_linear(np.array(_hex_to_rgb(self.colors[0]), dtype=np.float32))
44
+ c1 = _to_linear(np.array(_hex_to_rgb(self.colors[1]), dtype=np.float32))
45
+ blended_lin = c0 * (1 - t[..., None]) + c1 * t[..., None]
46
+ img_arr = np.clip(_to_srgb(blended_lin) * 255, 0, 255).astype(np.uint8)
47
+ return Image.fromarray(img_arr, "RGB")
48
+
49
+
50
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
51
+ return {
52
+ "colors": color_utils.random_palette(rng, 2, scheme="analogous"),
53
+ "center": (rng.uniform(0.25, 0.75), rng.uniform(0.25, 0.75)),
54
+ "radius": max(width, height) * rng.uniform(0.45, 0.85),
55
+ }
styles/shape_blur.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import colorsys
3
+ import numpy as np
4
+ from PIL import Image, ImageDraw, ImageFilter
5
+ from core.base import GradientStyle
6
+ from core import color_utils
7
+
8
+
9
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
10
+ h = hex_color.lstrip("#")
11
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
12
+
13
+
14
+ class ShapeBlurGradient(GradientStyle):
15
+ def __init__(
16
+ self,
17
+ width: int,
18
+ height: int,
19
+ bg_color: str,
20
+ blob_colors: list[str],
21
+ num_blobs: int = 5,
22
+ blur_radius: int = 80,
23
+ seed: int = None,
24
+ ):
25
+ super().__init__(width, height)
26
+ self.bg_color = bg_color
27
+ self.blob_colors = blob_colors
28
+ self.num_blobs = num_blobs
29
+ self.blur_radius = blur_radius
30
+ self.seed = seed
31
+
32
+ def render(self) -> Image.Image:
33
+ rng = random.Random(self.seed)
34
+
35
+ base = Image.new("RGBA", (self.width, self.height), (*_hex_to_rgb(self.bg_color), 255))
36
+
37
+ blob_layer = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
38
+ draw = ImageDraw.Draw(blob_layer)
39
+
40
+ for i in range(self.num_blobs):
41
+ color = self.blob_colors[i % len(self.blob_colors)]
42
+ r, g, b = _hex_to_rgb(color)
43
+ alpha = rng.randint(160, 220)
44
+ cx = rng.randint(-self.width // 6, self.width + self.width // 6)
45
+ cy = rng.randint(-self.height // 6, self.height + self.height // 6)
46
+ rx = rng.randint(self.width // 5, self.width // 2)
47
+ ry = rng.randint(self.height // 5, self.height // 2)
48
+ draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=(r, g, b, alpha))
49
+
50
+ blob_layer = blob_layer.filter(ImageFilter.GaussianBlur(radius=self.blur_radius))
51
+ composite = Image.alpha_composite(base, blob_layer)
52
+ return composite.convert("RGB")
53
+
54
+
55
+ def random_params(width: int, height: int, rng: random.Random) -> dict:
56
+ # Light, desaturated background — gives the "frosted glass" look
57
+ h = rng.random()
58
+ bg_r, bg_g, bg_b = colorsys.hsv_to_rgb(h, 0.08, 0.96)
59
+ bg_color = "#{:02x}{:02x}{:02x}".format(int(bg_r * 255), int(bg_g * 255), int(bg_b * 255))
60
+
61
+ # Blob colors: analogous family, vivid so they show through the blur
62
+ blob_colors = color_utils.random_palette(rng, rng.randint(3, 5), scheme="analogous")
63
+
64
+ blur = max(30, min(width, height) // 6)
65
+ return {
66
+ "bg_color": bg_color,
67
+ "blob_colors": blob_colors,
68
+ "num_blobs": rng.randint(4, 7),
69
+ "blur_radius": rng.randint(blur, blur * 2),
70
+ "seed": rng.randint(0, 2 ** 31),
71
+ }